突变后使用订阅和更新创建重复节点 - 使用 Apollo 客户端

Posted

技术标签:

【中文标题】突变后使用订阅和更新创建重复节点 - 使用 Apollo 客户端【英文标题】:Using subscription and update after mutation creates duplicate node - with Apollo Client 【发布时间】: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)

【问题讨论】:

你能发布一些相关的代码吗?也许订阅操作和突变更新。此外,完整的错误消息也会有所帮助。 【参考方案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;
                
            ,
        );
    ;

【讨论】:

以上是关于突变后使用订阅和更新创建重复节点 - 使用 Apollo 客户端的主要内容,如果未能解决你的问题,请参考以下文章

如何在突变后更新阿波罗缓存(使用过滤器查询)

Vuex 状态随突变而变化 - apollo graphql 查询

如何使用 mui-datatable 实现 aws 订阅

进行后续突变后中继不更新

GraphQL 在突变时返回 null [重复]

HotChocolate 突变输入类型使用 int 而不是 ID