Evanss
Evanss

Reputation: 23173

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

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)

Upvotes: 9

Views: 3146

Answers (2)

Evanss
Evanss

Reputation: 23173

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;
            }
        },
    });
};

Upvotes: 5

Locco0_0
Locco0_0

Reputation: 3500

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);
}

Upvotes: 1

Related Questions