apollographql / apollo-client

:rocket:  A fully-featured, production ready caching GraphQL client for every UI framework and GraphQL server.
https://apollographql.com/client
MIT License
19.38k stars 2.66k forks source link

Using `cache.modify` in a subscription with Typescript. #11623

Closed hickscorp closed 2 months ago

hickscorp commented 8 months ago

We have a subscription that notifies us when something new is added to a connection. In our React component tree, we have something like this:

export const Subscription = ({ id, bearerToken }: ObserverProps) => {
  const { cache } = useApolloClient();
  const sub = useSubscription(SUBSCRIPTION, {
    variables: { id, bearerToken },
  });
  useEffect(() => {
    if (sub.loading || !sub.data) return;
    cache.modify({
      id: cache.identify(sub.data.conversationStepCreated.conversation),
      fields: {
        steps: (existingRefs) => {
          const { edges } = existingRefs;
        },
      },
    });
  }, [cache, sub]);
  return null;
};

When the subscription ticks, we receive a Step entity, which should be added to its parent Conversation.steps connection in the cache so that UI updates automatically.

I am failing to understand how to use cache.modify to do so:

EDIT: We've tried fetching the "last one step" from the conversation upon the subscription firing - but it seems to be killing our cache alltogether and leaves it with just one entry.

export const Subscription = ({ id, bearerToken }: ObserverProps) => {
  const sub = useSubscription(SUBSCRIPTION, {
    variables: { id, bearerToken },
  });

  const [stepsQuery] = useLazyQuery(CONVERSATION_STEPS_QUERY, {
    fetchPolicy: "network-only",
  });

  useEffect(() => {
    if (sub.loading || !sub.data) return;
    stepsQuery({ variables: { id } });
  }, [id, stepsQuery, sub.loading, sub.data]);
  return null;
};
phryneas commented 8 months ago

I fear I'm still missing a bit of context here. Can you maybe take a few steps back and start describing what you want to do in the end?

That said:

hickscorp commented 8 months ago

Good morning and thank you @phryneas .

So from what we understood, a good way to do so would be to use cache.modify on the Connection edges to append the Step received by the subscription to the edges that were already fetched. What I don't understand is how to do this in a "type safety" fashion, or even how to approach the data structures that should be involved in pushing new elements to the cache. It'd be nice to have a working example of such scenario.

In response to your comments:

phryneas commented 8 months ago

This is what we want from the strong typing guarantees at compile-time - and instead we want the query to only fire when the variables become set.

Understood. Personally, I would prefer the better "runtime behaviour" over the type safety here, but that's very opinionated. In the future, you'll be able to do this in a more typesafe matter using skipToken, which is already available with useSuspenseQuery and which we will add to useQuery in the future.


Generally, you're in a bit of an edge case situation regarding type safety - usually, you'd do something like this:

      cache.modify<EntityType>({
        id: ...,
        fields: {
          fieldName(valueOrRef) {
            return differentValue
          }
        }
      });

You can see more usage examples in our tests, e.g. at https://github.com/apollographql/apollo-client/blob/9dc45bb8d13ebb486dd1cb9963e1e0d9787d984d/src/cache/core/__tests__/cache.ts#L348-L383

Your problem here is that your have deeper nesting here - which the cache will try to normalize, and if it's successful, nested fields like steps.edges here will only be another reference.

So depending on how your cache can be normalized, you'd have to actually call cache.modify for the instance stored in sub.data.conversationStepCreated.conversation.steps instead, with the appropriate type for that.

hickscorp commented 8 months ago

Thansk a lot @phryneas this is helpful.

I'm trying to simplify the codebase to give a clearer example, as it seems that the question was misunderstood. Will come back to you soon guys :)

hickscorp commented 8 months ago

Ok, so I think I found a way to ask way, way more simply.

Imagine a Post object with a connection field comments of type Comment. The client lists these comments by performing a "one shot" query to the node(id: postId) field. Classic. At the same time, it subscribes to something like CommentCreated(postId: ...) which gives back a Comment every time one is added to this given Post identified by its id.

I would imagine that the Apollo client cache would be configured with something like this:

new InMemoryCache({
    fragments: createFragmentRegistry(),
    typePolicies: {
      Post: {
        fields: {
          comments: relayStylePagination(),
        },
      },
    }

Cool.

You see - we were successful having these realtime comments added to the Post page - but we are adding them to a state array - not to the apollo cache backing the Post's comments connection. So when the user leaves the page and comes back, the state of the cache is shown without these new entries.

So the simplified question is: when the CommentCreated subscription fires, how do you append that new post to the existing list in the Apollo cache (using TypeScript and with type guarantees)?

hickscorp commented 8 months ago

We've been successful with modifying the cache, with something like this - this being the function that is called when the subscription fires:

  const onNewComment = (comment: DetailedCommentFragment) => {
    cache.modify({
      id: cache.identify({
        __typename: "Post",
        id: comment.post.id,
      }),
      fields: {
        comments(existing) {
          return {
            ...existing,
            edges: [...existing.edges, { node: comment }],
          };
        },
      },
    });

The proble here is that existing is any. There are no guarantees at all, and it feels like blind luck that it works because it could in fact be a concrete CommentConnection but it could also be a Reference... The moment we add a type hint to cache.modify<Post>(...) to our code, it breaks because we're not handling Reference... So we played around with readField but can't seem to succeed with finding the right types to use. Any sample code that would help deal with the Relay types (connections, edges having a cursor etc and appending to them) could be useful here.

In our case and translating Post into Conversation and Comment into Step with its connection on Post being steps:

  // Whenever a subscription catches a new step, that's the handler.
  const onNewStep = (step: DetailedStepFragment) => {
    cache.modify<Conversation>({
      id: cache.identify({
        __typename: "Conversation",
        id: step.conversation.id,
      }),
      fields: {
        steps(existing, { readField }) {
          const edges = readField("edges", existing) || [];
          return {
            ...existing,
            edges: [...edges, { node: step }],
          };
        },
      },
    });

This won't compile - because readField gives us a string | number | void | Readonly<Object> | Readonly<Reference> | readonly string[] | readonly Reference[] | null | undefined which doesn't match at all what we would expect - probably a ReadonlyArray<ConversationStepEdge>? But if we hard-code the hint (Eg readField<ReadonlyArray<ConversationStepEdge>>("edges", existing); does it really guarantee that it can be that and only that?

Here's what happens when we try:

  // Whenever a subscription catches a new step, that's the handler.
  const onNewStep = (step: DetailedStepFragment) => {
    cache.modify<Conversation>({
      id: cache.identify({
        __typename: "Conversation",
        id: step.conversation.id,
      }),
      fields: {
        steps(existing, { readField }) {
          const edges = readField<ReadonlyArray<ConversationStepEdge>>(
            "edges",
            existing
          );
          return edges
            ? {
                ...existing,
                edges: [...edges, { node: step }],
              }
            : existing;
        },
      },
    });

The error is:

Type '(existing: Reference | AsStoreObject<ConversationStepConnection>, { readField }: ModifierDetails) => Reference | { ...; } | { ...; }' is not assignable to type 'Modifier<Reference | AsStoreObject<ConversationStepConnection>>'.\n  Type 'Reference | { edges: (ConversationStepEdge | { node: DetailedStepFragment; })[]; __ref: string; } | { edges: (ConversationStepEdge | { ...; })[]; __typename?: \"ConversationStepConnection\" | undefined; pageInfo: PageInfo; }' is not assignable to type 'Reference | AsStoreObject<ConversationStepConnection> | DeleteModifier | InvalidateModifier'.\n    Type '{ edges: (ConversationStepEdge | { node: DetailedStepFragment; })[]; __typename?: \"ConversationStepConnection\" | undefined; pageInfo: PageInfo; }' is not assignable to type 'Reference | AsStoreObject<ConversationStepConnection> | DeleteModifier | InvalidateModifier'.\n      Type '{ edges: (ConversationStepEdge | { node: DetailedStepFragment; })[]; __typename?: \"ConversationStepConnection\" | undefined; pageInfo: PageInfo; }' is not assignable to type 'AsStoreObject<ConversationStepConnection>'.\n        Types of property 'edges' are incompatible.\n          Type '(ConversationStepEdge | { node: DetailedStepFragment; })[]' is not assignable to type 'ConversationStepEdge[]'.\n            Type 'ConversationStepEdge | { node: DetailedStepFragment; }' is not assignable to type 'ConversationStepEdge'.\n              Property 'cursor' is missing in type '{ node: DetailedStepFragment; }' but required in type 'ConversationStepEdge'.

Thanks a lot!

hickscorp commented 8 months ago

Also, looking at https://github.com/apollographql/apollo-client/blob/main/src/utilities/policies/pagination.ts#L94 it seems that this could do exactly what we're looking for - but we have no idea on how to use it after the subscription fires.

hickscorp commented 8 months ago

Ok - a bit of progress. It seems that we're getting a bit closer to type safety, if we use cache.updateQuery instead of cache.modify.

Would this be a good way to achieve what we want? It seems to be working in a (very) controlled environment. We were expecting that using cache.updateQuery would let the field policy kick-in - so that we wouldn't need to do the merges ourselves. But it doesn't - so we ended up with:

Seems to us that it will break - because all the logic that relayStylePagination() would do is completely bypassed. WDYT?

  const onNewStep = (step: DetailedStepFragment) => {
    cache.updateQuery(
      {
        query: GQL.Conversation.StepsQuery,
        variables: { id: step.conversation.id, last: 8 },
      },
      (existing) => {
        if (existing?.node?.__typename !== "Conversation") return;
        const node = frag(GQL.Conversation.WithSteps, existing.node);
        return {
          ...existing,
          node: {
            ...node,
            steps: {
              ...node.steps,
              edges: [
                ...node.steps.edges,
                { __typename: "ConversationStepEdge", node: step },
              ],
            },
          },
        };
      }
    );
  };
phryneas commented 3 months ago

I'm sorry, I dropped the ball on this issue, just too much to do in too little time :(

Did your solution work out for you in the end?

Also, as a heads up, we recently released Version 3.11 with a rewrite of useSubscription. It comes with an ignoreResults option, so you could set that to true and move your effect into the onData option callback. That would give you more control over component rerenders.

github-actions[bot] commented 2 months ago

We're closing this issue now but feel free to ping the maintainers or open a new issue if you still need support. Thank you!

github-actions[bot] commented 1 month ago

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. For general questions, we recommend using StackOverflow or our discord server.