gqty-dev/gqty

Results from useSubscriptio are undefined when use multiple hooks

AlexanderMoroz opened this issue · 11 comments

Hello!

Really enjoy working with gqty library and the idea behind this. Unfortunately we face one issue.
If we use useSubscription hook in different places in our application simultaneously results are not always the same.
Sometimes it looks like react doesn't know that it needs to update tree with fetched data. It happens not always even with same data.

For example we have:

export const CommentsList = () => {
  const subscription = useSubscription();
  const comments = subscription.comments();
  
  return (
    <div>
      {comments.map(comment => <Comment {...comment} />)}
    </div>
  );
};

and if we do console.log of the comments sometimes we have list of comment which is array of proxy objects, but sometimes it's just [undefined] which is always array with one undefined inside. So because it happens occasionally I expect that there is something related with speed of network + rendering speed of react.

Even if we have [undefined] we can see correct data returned in ws: tab in network.

We found a bit tricky way to get data buy just forcing react to re-render component and it helps, but would be great to hear any ideas what's wrong, probably with our implementation..

Our forceUpdate as a example how we avoid this 'stuck' subscriptions

const key = 'id';
const timeout = 500;

export const useForcedSubscription = (opts?: UseSubscriptionOptions) => {
  const subscription = useSubscription(opts);

  const [subscriptionResults, setSubscriptionResults] = useState<
    { name: string | symbol; result: NonNullable<SubscriptionResult> }[]
  >([]);

  const [_, forceUpdate] = useState(0);

  const proxy = useMemo(
    () =>
      new Proxy(subscription, {
        get: (target, prop, receiver) => {
          const getter = Reflect.get(target, prop, receiver);
          return (...args: Parameters<typeof getter>) => {
            const result: SubscriptionResult = getter(...args);
            if (result && !subscriptionResults.some((e) => e.name === prop)) {
              setSubscriptionResults((results) => {
                return [...results, { name: prop, result }];
              });
            }
            return result;
          };
        },
      }),
    [subscription, setSubscriptionResults, subscriptionResults]
  );

  const isValuesDefined = useCallback(
    (e: NonNullable<SubscriptionResult>) =>
      key in e ? e[key as keyof typeof e] : false,
    []
  );

  useEffect(() => {
    const intervals = subscriptionResults
      .map(({ result }) => {
        const isLoading = not(
          Array.isArray(result)
            ? result.some(isValuesDefined)
            : isValuesDefined(result)
        );

        if (!isLoading) return;
        return setInterval(() => forceUpdate((n) => n + 1), timeout);
      })
      .filter(Boolean);

    return () => {
      intervals.forEach(clearInterval);
    };
  }, [subscriptionResults, isValuesDefined, timeout]);

  return proxy;
};

So we just listen to what items will be triggered from subscription and if have this [undefined] array we will just set state to trigger one more render.

Issue is really hard to reproduce because as I said it happens occasionally in different part of application

Thanks in advance!

This sounds like an internal racing condition from our cache.

The [undefined] you are receiving is the data placeholder for skeleton loaders from query/mutations, it is not supposed to appear in subscriptions.

A force update hook is also what we used to trigger a rerender after a query successfully fetched.

I am in the middle of refactoring the query mechanism, it may take some time before I could look into this one.

In the mean time, would you mind sharing more information on what the websocket is sending?

I would like to replicate as much of your environment as possible when I try to reproduce it.

So... caught this bug but in different part of the app.

There is a list of websockets messages:

so we have connection:

{"id":null,"type":"connection_init","payload":{"headers":{"Authorization":"Bearer blablabla"}}}

and two responses

{"type":"ka"}
{type: "connection_ack"}

after I can see that we request subscriptions:
pins entity:

{
  id: '1',
  type: 'start',
  payload: {
    query:
      'subscription PinsOverlayDocumentListSubscription($where1:pins_bool_exp$order_by2:[pins_order_by!]$limit3:Int){pins_cd9b8_44036:pins(where:$where1 order_by:$order_by2){__typename id asset_id created_at is_in_trash resolved comments_394f9_7ad63:comments(limit:$limit3){__typename id}}}',
    variables: {
      where1: {
        is_in_trash: { _neq: true },
        asset_id: { _eq: 'qwwMVLk8qulGVC_YlMCP8' },
      },
      order_by2: [{ created_at: 'asc' }],
      limit3: 1,
    },
  },
};

and assets entity:

{
  id: '2',
  type: 'start',
  payload: {
    query:
      'subscription($where1:assets_bool_exp){assets_867e3_48aa5:assets(where:$where1){__typename id name file{__typename id name}sketches_c9c54_bf21a:sketches{__typename id}}}',
    variables: { where1: { project_id: { _eq: 'US54MEAdg1xdV_1GZj04g' } } },
  },
};

also assets once more??

{
  id: '3',
  type: 'start',
  payload: {
    query:
      'subscription($where1:assets_bool_exp){assets_867e3_48aa5:assets(where:$where1){__typename id name file{__typename id name}sketches_c9c54_bf21a:sketches{__typename id}}}',
    variables: { where1: { project_id: { _eq: 'US54MEAdg1xdV_1GZj04g' } } },
  },
};

with different ID but same variables...

and we have all three as messages coming back:

like this 
 {
  type: 'data',
  id: '2',
  payload: {
    data: {
      assets_867e3_48aa5: [
        {
          __typename: 'assets',
          id: 'S1XTr4IZcWi_4-s5RNaGe',
          ...
        },
        ...
      ],
    },
  },


id3: 
{
  type: 'data',
  id: '3',
  payload: {
    data: {
      assets_867e3_48aa5: [
        {
          __typename: 'assets',
          id: 'S1XTr4IZcWi_4-s5RNaGe',
          name: null,
          ...
        },
        ...
      ],
    },
  },
};

etc...

but for pins entity which is visible I can see we do quite a lot of request - 4 I guess, and they are displayed correctly.
For assets - we have one item without any data inside, instead of 5 real items, which we see in ws:// tab

also it's kind of hard to reproduce on dev environment, but happens more often on our deployed dev server with production build application

Hope that helps!

I found some points of interests,

  1. A repeated subscription may hint the concurrent mode in React 18, which GQty don't really support yet. Do you see this happening in your dev environment?
  2. A response without any data looks suspicious, could be hinting something in the production environment. What does the message look like?

We need a minimum reproducing repo with a mocked GraphQL/WebSocket server to test it out.

  1. Not sure how we can see concurrent mode. We try to avoid using suspense as much as possible, and not using in the part of application which are affected by this issue. Is there is any flags that can show us that it's in concurrent mode?

  2. What do you mean by empty response? Only empty responses that we have is a { type: 'ka' } and answer to connection {type: "connection_ack"}. All other have data. For empty assets in my message above I meant that we have in react tree only 1 item which return undefined for all fields instead of 5 real items with correct data which should be the same as in ws: message

We need to think how we can do reproducible repo with all access to you. Problem that as backend client we use third-party service. I'll come back to you as soon as I get any ideas

If you are using React 18, it is very likely that concurrent mode is on. The most prominent way to see it is to console.log() during component render. You will see 2x times it is supposed to run. Concurrent mode tries to render components twice, having one of them in the background so fetches and render lags are hidden.

This could mess up our fetch debounce in queries/mutations and how we manage active subscriptions on component mount. I'll work on React 18 support in the future.

By empty response I mean the following quote, did I understand it correctly?

we have one item without any data inside, instead of 5 real items, which we see in ws:// tab

The best way for a minimum repo is to init a brand new monorepo with

  1. React/Next with useSubscription(), and
  2. An Apollo Server (or other ws gql servers, so graphql-yoga doesn't work) with it's Subscription mocking your captured messages.

https://www.loom.com/share/b67da76b34614a2f9ab1b35e407d82b6

So! Finally create a small sandbox to replicate the issue. There is a demo above and link to sandbox:
https://codesandbox.io/p/github/mmmoli/gqty-subscriptions/main?file=%2FREADME.md

As a backend we use nhost, which uses hasura as graphql engine and we just hardcode link to our cloud instance, but I hope this will be enough for you

Thanks, it helps a lot! This will be the first thing after I finish refactoring.

@AlexanderMoroz our new core is going beta from alpha.

While we are updating our GitHub actions to properly release a beta tag on NPM, please feel free to read our updated docs at https://gqty.dev, install the latest alpha and try it out.

The new generated client uses graphql-ws so we expect existing issues on subscriptions to be largely gone.

Thanks @vicary ! Actually I’ve already started refactoring to v3 last week. I’ll keep you posted if face any issues.