Использование подписки и обновления после мутации создает дубликат узла-с клиентом Apollo

Im использует обновление после мутации для обновления магазина при создании нового комментария. У меня тоже есть подписка на комментарии на этой странице.

любой из этих методов работает так, как ожидалось. Однако, когда у меня есть оба, то пользователь, создавший комментарий, увидит комментарий на странице дважды и получит эту ошибку от React:

Warning: Encountered two children with the same key,

Я думаю, что причиной этого является обновление мутации и подписка как вернуть новый узел, создавая дублированные записи. Есть ли рекомендуемое решение для этого? Я ничего не видел в документах Apollo, но мне это не кажется таким уж крайним случаем использования.

это компонент с подпиской:

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;

и вот компонент NewComment:

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;

и полное сообщение об ошибке:

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)

2 ответов


Это на самом деле довольно легко исправить. Я долгое время был в замешательстве, так как мои подписки периодически терпели неудачу. Оказывается, это была проблема Graphcool, переход от Азиатского кластера к кластеру США остановил flakiness.

вам просто нужно проверить, существует ли ID уже в магазине, и не добавлять его, если это так. Ive добавил комментарии кода, где я изменил код:

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

я наткнулся на ту же проблему и не нашел простое и чистое решение.

то, что я сделал, использовало функциональность фильтра распознавателя подписки на сервере. Вы можете следить за этим учебник который описывает, как настроить сервер и это учебник для клиента.

короче:

  • добавить какой-то идентификатор сеанса браузера. Может быть, это токен JWT или какой-то другой уникальный ключ (например, UUID) в качестве запрос

type Query {
  getBrowserSessionId: ID!
}

Query: {
  getBrowserSessionId() {
    return 1; // some uuid
  },
}
  • сделать это на клиенте, и, например, сохранить его в локальном хранилище

...

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


...

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

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

...
  • добавить тип подписки с определенным ID в качестве параметра на сервере

type Subscription {
  messageAdded(browserSessionId: ID!): Message
}
  • на распознавателе добавьте фильтр для идентификатора сеанса браузера

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;
      }
    )
  }
}
  • когда вы добавьте подписку на запрос вы добавляете идентификатор сеанса браузера в качестве параметра

...

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 
    }
  });
}
  • на мутации на сервере вы также добавляете идентификатор сеанса браузера в качестве параметра

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

...

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

  ...
  
  pubsub.publish(‘messageAdded’, {
    messageAdded: newMessage,
    browserSessionId
  });
  return newMessage;
}
  • при вызове мутации вы добавляете идентификатор сеанса браузера из локального хранилища и выполняете обновление запроса в функции обновления. Теперь запрос должен обновляться из мутация в браузере, где мутация отправляется и обновляется на других из подписки.

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