Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using subscription and update after mutation creates duplicate node - with Apollo Client

Tags:

apollo

Im using update after a mutation to update the store when a new comment is created. I also have a subscription for comments on this page.

Either one of these methods works as expected by itself. However when I have both, then the user who created the comment will see the comment on the page twice and get this error from React:

Warning: Encountered two children with the same key,

I think the reason for this is the mutation update and the subscription both return a new node, creating a duplicate entry. Is there a recommended solution to this? I couldn’t see anything in the Apollo docs but it doesn’t seem like that much of an edge use case to me.

This is the component with my subscription:

import React from 'react';
import { graphql, compose } from 'react-apollo';
import gql from 'graphql-tag';
import Comments from './Comments';
import NewComment from './NewComment';
import _cloneDeep from 'lodash/cloneDeep';
import Loading from '../Loading/Loading';

class CommentsEventContainer extends React.Component {
    _subscribeToNewComments = () => {
        this.props.COMMENTS.subscribeToMore({
            variables: {
                eventId: this.props.eventId,
            },
            document: gql`
                subscription newPosts($eventId: ID!) {
                    Post(
                        filter: {
                            mutation_in: [CREATED]
                            node: { event: { id: $eventId } }
                        }
                    ) {
                        node {
                            id
                            body
                            createdAt
                            event {
                                id
                            }
                            author {
                                id
                            }
                        }
                    }
                }
            `,
            updateQuery: (previous, { subscriptionData }) => {
                // Make vars from the new subscription data
                const {
                    author,
                    body,
                    id,
                    __typename,
                    createdAt,
                    event,
                } = subscriptionData.data.Post.node;
                // Clone store
                let newPosts = _cloneDeep(previous);
                // Add sub data to cloned store
                newPosts.allPosts.unshift({
                    author,
                    body,
                    id,
                    __typename,
                    createdAt,
                    event,
                });
                // Return new store obj
                return newPosts;
            },
        });
    };

    _subscribeToNewReplies = () => {
        this.props.COMMENT_REPLIES.subscribeToMore({
            variables: {
                eventId: this.props.eventId,
            },
            document: gql`
                subscription newPostReplys($eventId: ID!) {
                    PostReply(
                        filter: {
                            mutation_in: [CREATED]
                            node: { replyTo: { event: { id: $eventId } } }
                        }
                    ) {
                        node {
                            id
                            replyTo {
                                id
                            }
                            body
                            createdAt
                            author {
                                id
                            }
                        }
                    }
                }
            `,
            updateQuery: (previous, { subscriptionData }) => {
                // Make vars from the new subscription data
                const {
                    author,
                    body,
                    id,
                    __typename,
                    createdAt,
                    replyTo,
                } = subscriptionData.data.PostReply.node;
                // Clone store
                let newPostReplies = _cloneDeep(previous);
                // Add sub data to cloned store
                newPostReplies.allPostReplies.unshift({
                    author,
                    body,
                    id,
                    __typename,
                    createdAt,
                    replyTo,
                });
                // Return new store obj
                return newPostReplies;
            },
        });
    };

    componentDidMount() {
        this._subscribeToNewComments();
        this._subscribeToNewReplies();
    }

    render() {
        if (this.props.COMMENTS.loading || this.props.COMMENT_REPLIES.loading) {
            return <Loading />;
        }

        const { eventId } = this.props;
        const comments = this.props.COMMENTS.allPosts;
        const replies = this.props.COMMENT_REPLIES.allPostReplies;
        const { user } = this.props.COMMENTS;

        const hideNewCommentForm = () => {
            if (this.props.hideNewCommentForm === true) return true;
            if (!user) return true;
            return false;
        };

        return (
            <React.Fragment>
                {!hideNewCommentForm() && (
                    <NewComment
                        eventId={eventId}
                        groupOrEvent="event"
                        queryToUpdate={COMMENTS}
                    />
                )}
                <Comments
                    comments={comments}
                    replies={replies}
                    queryToUpdate={{ COMMENT_REPLIES, eventId }}
                    hideNewCommentForm={hideNewCommentForm()}
                />
            </React.Fragment>
        );
    }
}

const COMMENTS = gql`
    query allPosts($eventId: ID!) {
        user {
            id
        }
        allPosts(filter: { event: { id: $eventId } }, orderBy: createdAt_DESC) {
            id
            body
            createdAt
            author {
                id
            }
            event {
                id
            }
        }
    }
`;

const COMMENT_REPLIES = gql`
    query allPostReplies($eventId: ID!) {
        allPostReplies(
            filter: { replyTo: { event: { id: $eventId } } }
            orderBy: createdAt_DESC
        ) {
            id
            replyTo {
                id
            }
            body
            createdAt
            author {
                id
            }
        }
    }
`;

const CommentsEventContainerExport = compose(
    graphql(COMMENTS, {
        name: 'COMMENTS',
    }),
    graphql(COMMENT_REPLIES, {
        name: 'COMMENT_REPLIES',
    }),
)(CommentsEventContainer);

export default CommentsEventContainerExport;

And here is the NewComment component:

import React from 'react';
import { compose, graphql } from 'react-apollo';
import gql from 'graphql-tag';
import './NewComment.css';
import UserPic from '../UserPic/UserPic';
import Loading from '../Loading/Loading';

class NewComment extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            body: '',
        };
        this.handleChange = this.handleChange.bind(this);
        this.handleSubmit = this.handleSubmit.bind(this);
        this.onKeyDown = this.onKeyDown.bind(this);
    }

    handleChange(e) {
        this.setState({ body: e.target.value });
    }

    onKeyDown(e) {
        if (e.keyCode === 13) {
            e.preventDefault();
            this.handleSubmit();
        }
    }

    handleSubmit(e) {
        if (e !== undefined) {
            e.preventDefault();
        }

        const { groupOrEvent } = this.props;
        const authorId = this.props.USER.user.id;
        const { body } = this.state;
        const { queryToUpdate } = this.props;

        const fakeId = '-' + Math.random().toString();
        const fakeTime = new Date();

        if (groupOrEvent === 'group') {
            const { locationId, groupId } = this.props;

            this.props.CREATE_GROUP_COMMENT({
                variables: {
                    locationId,
                    groupId,
                    body,
                    authorId,
                },

                optimisticResponse: {
                    __typename: 'Mutation',
                    createPost: {
                        __typename: 'Post',
                        id: fakeId,
                        body,
                        createdAt: fakeTime,
                        reply: null,
                        event: null,
                        group: {
                            __typename: 'Group',
                            id: groupId,
                        },
                        location: {
                            __typename: 'Location',
                            id: locationId,
                        },
                        author: {
                            __typename: 'User',
                            id: authorId,
                        },
                    },
                },

                update: (proxy, { data: { createPost } }) => {
                    const data = proxy.readQuery({
                        query: queryToUpdate,
                        variables: {
                            groupId,
                            locationId,
                        },
                    });

                    data.allPosts.unshift(createPost);
                    proxy.writeQuery({
                        query: queryToUpdate,
                        variables: {
                            groupId,
                            locationId,
                        },
                        data,
                    });
                },
            });
        } else if (groupOrEvent === 'event') {
            const { eventId } = this.props;

            this.props.CREATE_EVENT_COMMENT({
                variables: {
                    eventId,
                    body,
                    authorId,
                },

                optimisticResponse: {
                    __typename: 'Mutation',
                    createPost: {
                        __typename: 'Post',
                        id: fakeId,
                        body,
                        createdAt: fakeTime,
                        reply: null,
                        event: {
                            __typename: 'Event',
                            id: eventId,
                        },
                        author: {
                            __typename: 'User',
                            id: authorId,
                        },
                    },
                },

                update: (proxy, { data: { createPost } }) => {
                    const data = proxy.readQuery({
                        query: queryToUpdate,
                        variables: { eventId },
                    });

                    data.allPosts.unshift(createPost);

                    proxy.writeQuery({
                        query: queryToUpdate,
                        variables: { eventId },
                        data,
                    });
                },
            });
        }
        this.setState({ body: '' });
    }

    render() {
        if (this.props.USER.loading) return <Loading />;

        return (
            <form
                onSubmit={this.handleSubmit}
                className="NewComment NewComment--initial section section--padded"
            >
                <UserPic userId={this.props.USER.user.id} />

                <textarea
                    value={this.state.body}
                    onChange={this.handleChange}
                    onKeyDown={this.onKeyDown}
                    rows="3"
                />
                <button className="btnIcon" type="submit">
                    Submit
                </button>
            </form>
        );
    }
}

const USER = gql`
    query USER {
        user {
            id
        }
    }
`;

const CREATE_GROUP_COMMENT = gql`
    mutation CREATE_GROUP_COMMENT(
        $body: String!
        $authorId: ID!
        $locationId: ID!
        $groupId: ID!
    ) {
        createPost(
            body: $body
            authorId: $authorId
            locationId: $locationId
            groupId: $groupId
        ) {
            id
            body
            author {
                id
            }
            createdAt
            event {
                id
            }
            group {
                id
            }
            location {
                id
            }
            reply {
                id
                replyTo {
                    id
                }
            }
        }
    }
`;

const CREATE_EVENT_COMMENT = gql`
    mutation CREATE_EVENT_COMMENT($body: String!, $eventId: ID!, $authorId: ID!) {
        createPost(body: $body, authorId: $authorId, eventId: $eventId) {
            id
            body
            author {
                id
            }
            createdAt
            event {
                id
            }
        }
    }
`;

const NewCommentExport = compose(
    graphql(CREATE_GROUP_COMMENT, {
        name: 'CREATE_GROUP_COMMENT',
    }),
    graphql(CREATE_EVENT_COMMENT, {
        name: 'CREATE_EVENT_COMMENT',
    }),
    graphql(USER, {
        name: 'USER',
    }),
)(NewComment);

export default NewCommentExport;

And the full error message is:

Warning: Encountered two children with the same key, `cjexujn8hkh5x0192cu27h94k`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.
    in ul (at Comments.js:9)
    in Comments (at CommentsEventContainer.js:157)
    in CommentsEventContainer (created by Apollo(CommentsEventContainer))
    in Apollo(CommentsEventContainer) (created by Apollo(Apollo(CommentsEventContainer)))
    in Apollo(Apollo(CommentsEventContainer)) (at EventPage.js:110)
    in section (at EventPage.js:109)
    in DocumentTitle (created by SideEffect(DocumentTitle))
    in SideEffect(DocumentTitle) (at EventPage.js:51)
    in EventPage (created by Apollo(EventPage))
    in Apollo(EventPage) (at App.js:176)
    in Route (at App.js:171)
    in Switch (at App.js:94)
    in div (at App.js:93)
    in main (at App.js:80)
    in Router (created by BrowserRouter)
    in BrowserRouter (at App.js:72)
    in App (created by Apollo(App))
    in Apollo(App) (at index.js:90)
    in QueryRecyclerProvider (created by ApolloProvider)
    in ApolloProvider (at index.js:89)
like image 273
Evanss Avatar asked Mar 16 '18 04:03

Evanss


People also ask

How do you update the Apollo cache after a mutation?

If a mutation updates a single existing entity, Apollo Client can automatically update that entity's value in its cache when the mutation returns. To do so, the mutation must return the id of the modified entity, along with the values of the fields that were modified.

How do you update mutations in GraphQL?

Update mutations take filter as an input to select specific objects. You can specify set and remove operations on fields belonging to the filtered objects. It returns the state of the objects after updating. Note Executing an empty remove {} or an empty set{} doesn't have any effect on the update mutation.

Which transport does Apollo use to implement subscriptions?

Subscriptions are usually implemented with WebSockets, where the server holds a steady connection to the client. This means when working with subscriptions, we're breaking the Request-Response cycle that is typically used for interactions with the API.


2 Answers

This is actually pretty easy to fix. I was confused for a long time as my subscriptions would intermittently fail. It turns out this was a Graphcool issue, switching from the Asian to the USA cluster stoped the flakiness.

You just have to test to see if the ID already exists in the store, and not add it if it does. Ive added code comments where I've changed the code:

_subscribeToNewComments = () => {
        this.props.COMMENTS.subscribeToMore({
            variables: {
                eventId: this.props.eventId,
            },
            document: gql`
                subscription newPosts($eventId: ID!) {
                    Post(
                        filter: {
                            mutation_in: [CREATED]
                            node: { event: { id: $eventId } }
                        }
                    ) {
                        node {
                            id
                            body
                            createdAt
                            event {
                                id
                            }
                            author {
                                id
                            }
                        }
                    }
                }
            `,
            updateQuery: (previous, { subscriptionData }) => {
                const {
                    author,
                    body,
                    id,
                    __typename,
                    createdAt,
                    event,
                } = subscriptionData.data.Post.node;

                let newPosts = _cloneDeep(previous);

                // Test to see if item is already in the store
                const idAlreadyExists =
                    newPosts.allPosts.filter(item => {
                        return item.id === id;
                    }).length > 0;

                // Only add it if it isn't already there
                if (!idAlreadyExists) {
                    newPosts.allPosts.unshift({
                        author,
                        body,
                        id,
                        __typename,
                        createdAt,
                        event,
                    });
                    return newPosts;
                }
            },
        });
    };

    _subscribeToNewReplies = () => {
        this.props.COMMENT_REPLIES.subscribeToMore({
            variables: {
                eventId: this.props.eventId,
            },
            document: gql`
                subscription newPostReplys($eventId: ID!) {
                    PostReply(
                        filter: {
                            mutation_in: [CREATED]
                            node: { replyTo: { event: { id: $eventId } } }
                        }
                    ) {
                        node {
                            id
                            replyTo {
                                id
                            }
                            body
                            createdAt
                            author {
                                id
                            }
                        }
                    }
                }
            `,
            updateQuery: (previous, { subscriptionData }) => {
                const {
                    author,
                    body,
                    id,
                    __typename,
                    createdAt,
                    replyTo,
                } = subscriptionData.data.PostReply.node;

                let newPostReplies = _cloneDeep(previous);

                 // Test to see if item is already in the store
                const idAlreadyExists =
                    newPostReplies.allPostReplies.filter(item => {
                        return item.id === id;
                    }).length > 0;

                // Only add it if it isn't already there
                if (!idAlreadyExists) {
                    newPostReplies.allPostReplies.unshift({
                        author,
                        body,
                        id,
                        __typename,
                        createdAt,
                        replyTo,
                    });
                    return newPostReplies;
                }
            },
        });
    };
like image 159
Evanss Avatar answered Oct 15 '22 13:10

Evanss


I stumbled upon the same problem and did not find an easy and clean solution.

What i did was using the filter functionality of the subscription resolver on the server. You can follow this tutorial which describes how to set up the server and this tutorial for the client.

In short:

  • Add some kind of browser session id. May it be the JWT token or some other unique key (e.g. UUID) as a query

type Query {
  getBrowserSessionId: ID!
}

Query: {
  getBrowserSessionId() {
    return 1; // some uuid
  },
}
  • Get it on the client and e.g. save it to the local storage

...

if (!getBrowserSessionIdQuery.loading) {
  localStorage.setItem("browserSessionId", getBrowserSessionIdQuery.getBrowserSessionId);
}


...

const getBrowserSessionIdQueryDefinition = gql`
query getBrowserSessionId {
   getBrowserSessionId
}
`;

const getBrowserSessionIdQuery = graphql(getBrowserSessionIdQueryDefinition, {
   name: "getBrowserSessionIdQuery"
});

...
  • Add a subscription type with a certain id as parameter on the server

type Subscription {
  messageAdded(browserSessionId: ID!): Message
}
  • On the resolver add a filter for the browser session id

import { withFilter } from ‘graphql-subscriptions’;

...

Subscription: {
  messageAdded: {
    subscribe: withFilter(
      () => pubsub.asyncIterator(‘messageAdded’),
      (payload, variables) => {
      // do not update the browser with the same sessionId with which the mutation is performed
        return payload.browserSessionId !== variables.browserSessionId;
      }
    )
  }
}
  • When you add the subscription to the query you add the browser session id as parameter

...

const messageSubscription= gql`
subscription messageAdded($browserSessionId: ID!) {
   messageAdded(browserSessionId: $browserSessionId) {
     // data from message
   }
}
`

...

componentWillMount() {
  this.props.data.subscribeToMore({
    document: messagesSubscription,
    variables: {
      browserSessionId: localStorage.getItem("browserSessionId"),
    },
    updateQuery: (prev, {subscriptionData}) => {
      // update the query 
    }
  });
}
  • On the mutation on the server you also add the browser session id as parameter

`Mutation {
   createMessage(message: MessageInput!, browserSessionId: ID!): Message!
}`

...

createMessage: (_, { message, browserSessionId }) => {
  const newMessage ...

  ...
  
  pubsub.publish(‘messageAdded’, {
    messageAdded: newMessage,
    browserSessionId
  });
  return newMessage;
}
  • When you call the mutation you add the browser session id from the local storage and perform the updating of the query in the update functionality. Now the query should update from the mutation on the browser where the mutation is send and update on the others from the subscription.

const createMessageMutation = gql`
mutation createMessage($message: MessageInput!, $browserSessionId: ID!) {
   createMessage(message: $message, browserSessionId: $browserSessionId) {
      ...
   }
}
`

...

graphql(createMessageMutation, {
   props: ({ mutate }) => ({
      createMessage: (message, browserSessionId) => {
         return mutate({
            variables: {
               message,
               browserSessionId,
            },
            update: ...,
         });
      },
   }),
});

...

_onSubmit = (message) => {
  const browserSessionId = localStorage.getItem("browserSessionId");

  this.props.createMessage(message, browserSessionId);
}
like image 44
Locco0_0 Avatar answered Oct 15 '22 14:10

Locco0_0