urql-graphql / urql

The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
https://urql.dev/goto/docs
MIT License
8.6k stars 448 forks source link

GraphQL SSE React Native Subscription is not working but works perfectly with React.js. #3315

Closed CrispenGari closed 1 year ago

CrispenGari commented 1 year ago

Describe the bug

I'm using the urql implementation to with graphql-sse to create a graphql client as follows:

import {
  Client,
  cacheExchange,
  fetchExchange,
  subscriptionExchange,
} from "urql";
import { KEYS, serverDomain } from "../constants";
import { del, retrieve } from "../utils";
import { createClient as createSSEClient } from "graphql-sse";
import { authExchange } from "@urql/exchange-auth";
import { getToken, setToken } from "../state/token";

const sseClient = createSSEClient({
  url: `http://${serverDomain}/graphql`,
});
export const client = new Client({
  url: `http://${serverDomain}/graphql`,
  requestPolicy: "network-only",
  exchanges: [
    cacheExchange,
    fetchExchange,
    authExchange(async (utils) => {
      const jwt = await retrieve(KEYS.TOKEN_KEY);
      setToken(jwt);
      return {
        addAuthToOperation(operation) {
          if (jwt) {
            return utils.appendHeaders(operation, {
              Authorization: `Bearer ${jwt}`,
            });
          }
          return operation;
        },
        willAuthError(_operation) {
          return !jwt;
        },
        didAuthError(error, _operation) {
          return error.graphQLErrors.some(
            (e) => e.extensions?.code === "FORBIDDEN"
          );
        },
        async refreshAuth() {
          setToken(null);
          await del(KEYS.TOKEN_KEY);
        },
      };
    }),
    subscriptionExchange({
      forwardSubscription(operation) {
        return {
          subscribe: (sink) => {
            const dispose = sseClient.subscribe(operation as any, sink);
            return {
              unsubscribe: dispose,
            };
          },
        };
      },
    }),
  ],
  fetchOptions: () => {
    const token = getToken();
    return {
      headers: { authorization: `Bearer ${token || ""}` },
    };
  },
});

Then when try to use the useSubscription hook as follows to listen to new incoming subscriptions in my component as follows:

import { COLORS, FONTS } from "../../constants";
import { AppParamList } from "../../params";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { HomeStack } from "./home";
import { FriendsStack } from "./friends";
import { NotificationsStack } from "./notifications";
import { SettingsStack } from "./settings";
import TabIcon from "../../components/TabIcon/TabIcon";
import {
  MaterialCommunityIcons,
  MaterialIcons,
  Ionicons,
} from "@expo/vector-icons";
import { useMeStore } from "../../store";
import { useSubscription } from "urql";
const Tab = createBottomTabNavigator<AppParamList>();

const Doc = `
  subscription OnNewFriendRequest($input: OnNewFriendRequestInputType!) {
    onNewFriendRequest(input: $input) {
      message
      friend {
        id
        nickname
      }
    }
  }
`;
export const AppTabs = () => {
  const { me } = useMeStore();
  const [{ data, fetching, error }] = useSubscription({
    query: Doc,
    variables: {
      input: { id: "ef7c5256-80f0-4de0-9893-51f3e3e9926e" },
    },
  });

  console.log(JSON.stringify({ data, fetching, error }, null, 2));
  return (
    <Tab.Navigator
      initialRouteName="Home"
      screenOptions={{
        headerShown: false,
        tabBarHideOnKeyboard: true,
        tabBarStyle: {
          elevation: 0,
          shadowOpacity: 0,
          borderTopWidth: 0,
          borderColor: "transparent",
          backgroundColor: COLORS.primary,
          paddingVertical: 10,
          height: 80,
          width: "auto",
        },
        tabBarShowLabel: false,
        tabBarBadgeStyle: {
          backgroundColor: "cornflowerblue",
          color: "white",
          fontSize: 10,
          maxHeight: 20,
          maxWidth: 20,
          marginLeft: 3,
        },
        tabBarVisibilityAnimationConfig: {
          hide: {
            animation: "timing",
          },
          show: {
            animation: "spring",
          },
        },
        tabBarItemStyle: {
          width: "auto",
        },
      }}
    >
      <Tab.Screen
        options={{
          tabBarIcon: (props) => (
            <TabIcon
              {...props}
              title="home"
              Icon={{
                name: "home-account",
                IconComponent: MaterialCommunityIcons,
              }}
            />
          ),
        }}
        name="Home"
        component={HomeStack}
      />
      <Tab.Screen
        options={{
          tabBarIcon: (props) => (
            <TabIcon
              {...props}
              title="friends"
              Icon={{
                name: "person-search",
                IconComponent: MaterialIcons,
              }}
            />
          ),
        }}
        name="Friends"
        component={FriendsStack}
      />
      <Tab.Screen
        options={{
          tabBarIcon: (props) => (
            <TabIcon
              {...props}
              title="notifications"
              Icon={{
                name: "notifications",
                IconComponent: Ionicons,
              }}
            />
          ),
        }}
        name="Notifications"
        component={NotificationsStack}
      />
      <Tab.Screen
        options={{
          tabBarIcon: (props) => (
            <TabIcon
              {...props}
              title="settings"
              Icon={{
                name: "settings",
                IconComponent: Ionicons,
              }}
            />
          ),
        }}
        name="Settings"
        component={SettingsStack}
      />
    </Tab.Navigator>
  );
};

In my logs in a react-native application i'm only getting the loading state to true as follows:

{
  "fetching": true
}

But in my React web app when a new subscription is fired i'm getting the expected results as follows:

{
  "data": {
    "onNewFriendRequest": null
  },
  "fetching": false
}

Why am i getting this different behaviour in react-native?.

I raised this issue as a disscussion on graphql-sse: #68 but they are saying the issue might be with urql itself. @kitten Phil do you have any idea why is this happening.

Reproduction

None

Urql version

urql v^2.1.4

Validations

kitten commented 1 year ago

If you can create a reproduction in isolation I'm happy to take a look, however, there's no logic that isn't common between React and React Native bindings as they're identical. I can't accept pasted code snippets as a reproduction for an issue as such.

Are you aware as well that your authExchange isn't actually used for the fetchExchange?

I'll also suggest to check whether you have a duplicate and outdated version installed for @urql/core as there's an old bug associated with this.

Lastly, there are two known issues associated with React Native. One probably doesn't matter in this case, unless you haven't checked error.networkError, which shouldn't be the case here. The other is that React Native used to cause issues when some kind of Promise collapse happens.

Specifically, we used to see issues when AsyncStorage is called and in its promise resolution fetch is called subsequently. Have you isolated the issue by removing your auth code?

Edit: Lastly, actually, have you tried what happens when you don't use GraphQL SSE? The transport is built into urql and, with some restrictions, both the multipart and SSE response protocols can be used in urql (e.g. when you're using GraphQL Yoga) without any additional packages by enabling fetchSubscriptions: true on the Client https://github.com/urql-graphql/urql/tree/main/examples/with-subscriptions-via-fetch

CrispenGari commented 1 year ago

@kitten Thanks Phil, do you mind if i can share the github link to my project so that you can have a look at it and see where am not configuring the client correctly.

https://github.com/CrispenGari/invitee

And i might also need a good explanation, on Are you aware as well that your authExchange isn't actually used for the fetchExchange? How is that so ?

kitten commented 1 year ago

The importance of order of exchanges is repeated throughout the documentation quite a lot, so please read it carefully 😅

Exchanges are used in order and process operations in order (forwards) and then send or process results in order (backwards).

As such, your fetchExchange handles queries without the authExchange ever seeing them. But you also define both fetchOptions and an authExchange. The latter should override the former, but only if it handles an operation.

I can't accept a full app as a reproduction, sorry. If I ran everyone's apps, debug, and narrow down issues for them, that skips the most important step and causes me to basically spend hours on understanding and running your app, and to then create a reproduction myself.

A reproduction means that you've isolated the issue to a minimal piece of code that not only causes the issue, but can be used to pinpoint it.

Sorry, but without any indication of what this issue is, I can't commit to doing that myself 😅

kitten commented 1 year ago

@CrispenGari Hiya 👋

Just to point some things out, since I've had more time to think about this. I'm not sure how graphql-sse handles this, but I'm not fully convinced that Fetch streams are supported by React Native, see: https://github.com/facebook/react-native/issues/27741

I don't know if graphql-sse has a fall back that is supported, but I can at least say that the built-in implementation that @urql/core has for SSE and multipart streamed responses will not work on React Native given this limitation.

The only way to (probably) work around this would be for graphql-sse to explicitly allow for EventSource to be used and plugged in — however, afaik, their implementation is also based on fetch’s async iterable / body stream API.

You could attempt to basically use this library to basically “ponyfill” this functionality into your app, but it'd require implementing either a GraphQL SSE-like implementation or a whole urql Exchange. https://github.com/binaryminds/react-native-sse

However, overall, I don't think it's reasonable for either graphql-sse or us to maintain code to use XMLHttpRequest or EventSource via a polyfill like this one, to restore this functionality for React Native, as it'd be expected for the “host platform” to support async fetch response streams.

So, given the inactivity, I'll go ahead and will close this for now, but happy to take more comments here if you have any further questions 🙌

Edit: @enisdenjo; there is a sample of creating your own EventSource against your API, as long as you'd use EventSource from react-native-sse, e.g.:

const url = new URL('http://localhost:4000/graphql');
url.searchParams.append('query', 'subscription { greetings }');

const source = new EventSource(url);

source.addEventListener('next', ({ data }) => {
  console.log(data); // { "data": { "greetings": "Hi" } }
});

source.addEventListener('complete', () => {
  source.close();
});

So, if you're willing to wrap this yourself, you could implement this yourself

kitten commented 1 year ago

Quick update 👋

@enisdenjo kindly posted an example of how to hook up a standard EventSource API on the client to a GraphQL over SSE API: https://the-guild.dev/graphql/sse/recipes#with-eventsource-distinct-connections-mode