【问题标题】:Using subscription and update after mutation creates duplicate node - with Apollo Client突变后使用订阅和更新创建重复节点 - 使用 Apollo 客户端
【发布时间】:2018-08-25 01:43:57
【问题描述】:

当创建新评论时,我在突变后使用更新来更新商店。我在这个页面上也订阅了 cmets。

这些方法中的任何一种都可以按预期工作。但是,当我同时拥有两者时,创建评论的用户将在页面上看到两次评论并从 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)

【问题讨论】:

  • 你能发布一些相关的代码吗?也许订阅操作和突变更新。此外,完整的错误消息也会有所帮助。

标签: apollo


【解决方案1】:

我偶然发现了同样的问题,但没有找到简单而干净的解决方案。

我所做的是使用服务器上订阅解析器的过滤功能。你可以关注这个tutorial,它描述了如何设置服务器和这个tutorial为客户端。

简而言之:

  • 添加某种浏览器会话 ID。可能是 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
}
  • 在解析器上为浏览器会话 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;
      }
    )
  }
}
  • 当您向查询添加订阅时,您将浏览器会话 ID 添加为参数

...

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 
    }
  });
}
  • 在服务器上的变更中,您还可以添加浏览器会话 ID 作为参数

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

...

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

  ...
  
  pubsub.publish(‘messageAdded’, {
    messageAdded: newMessage,
    browserSessionId
  });
  return newMessage;
}
  • 当您调用突变时,您会从本地存储中添加浏览器会话 ID,并在更新功能中执行查询更新。现在,查询应该从发送突变的浏览器上的突变更新,并从订阅中更新其他查询。

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

【讨论】:

    【解决方案2】:

    这实际上很容易解决。我很困惑,因为我的订阅会间歇性地失败。事实证明这是一个 Graphcool 问题,从亚洲集群切换到美国集群阻止了这种脆弱性。

    您只需测试该 ID 是否已存在于商店中,如果存在则不要添加它。我在更改代码的地方添加了代码 cmets:

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

    【讨论】:

      猜你喜欢
      • 2018-12-19
      • 1970-01-01
      • 2021-09-04
      • 2021-10-06
      • 2019-04-28
      • 2017-12-20
      • 2020-02-03
      • 2018-09-03
      • 2021-11-18
      相关资源
      最近更新 更多