Apollo-client: Cursor jumps to end of react controlled input field.

Created on 18 Apr 2018  ·  24Comments  ·  Source: apollographql/apollo-client

Intended outcome:

Mutating the value on a controlled React input field should maintain the cursor position

Actual outcome:

The cursor moves to the end of the input field when a new character is typed on the keyboard.

How to reproduce the issue:

1) Create an Apollo GraphQL client only mutation resolver that mutate apollo-link-state by writing a fragment.
2) Attach the mutation to the onChange event of the controlled input field.
3) The mutation works but the cursor jumps to the end of the field on each keystroke.

const resolvers = {
Mutation: {
editBook: (obj, args, context, info) => {
const id = Book:${args.id};
const fragment = gql fragment book on Book { id title author } ;
let book = context.cache.readFragment({fragment, id});
book.title = args.title;
context.cache.writeFragment({fragment, id, book});
return book;
}
}
};

const cache = new InMemoryCache();
const stateLink = withClientState({
cache,
resolvers: resolvers,
defaults: defaults,
typeDefs: typeDefs
});

const client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError, ...other }) => {
if (graphQLErrors)
graphQLErrors.map(({ message, locations, path }) =>
console.log(
[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path},
),
);
if (networkError) console.log([Network error]: ${networkError});
}),
stateLink,
// queueLink,
new HttpLink({
uri: '/graphql',
credentials: 'same-origin'
})
]),
cache
});

const bookQuery = graphql(BOOKQUERY, {
options: (props) => {
return ({
variables: { id: props.id}
})
}
});

const Book = ({data: {loading, error, book}, editBook}) => {
if (loading) {
console.log('loading');
return (
null
)
}
console.log('Book, book', book);
return (


name='title'
type='text'
value={book.title}
onChange={(e) => {
console.log(e.target.value);
editBook({variables: {id: book.id, title: e.target.value}});
}}
/>

)
};

const ShowBook = compose(
bookQuery,
graphql(MUTATION_EDITBOOK, {name: 'editBook'})
) (Book);

Version

✔ confirmed 🙏 help-wanted 🚧 in-triage

Most helpful comment

@hwillson https://codesandbox.io/s/j7o67llwl3

Here is an example :)

Type "aaaa" in the bar, then try to insert bbb in the middle of the string like "aabbbaa".

You will get "aabaabb". Because cursor jumps to the end after 1 change. This is due to the value of the input not updating fast enough due to asynchronous state. When you look at the actual renders.

Input gets the new b, but then on render cycle, the value of it doesn't contain it yet, so it writes aaaa, then gets updated through the store and gets aabaa, but then cursor doesn't match with the previous input anymore due to double render.

All 24 comments

Any "temp" fix for this?

Running into the same issue. Is there any update here?

Same here. Just getting started with apollo-link-state, so I'm not entirely sure if this is something I've wired up incorrectly...

Version

Hi, I ended up using a textarea that works as expected.

Any update? This makes apollo link state barely usable, jumping cursors is not a nice experience.

A temp hack around it would also be helpful :)

I ended up using a textarea.

Any update? This makes apollo link state barely usable, jumping cursors is not a nice experience.

A temp hack around it would also be helpful :)

You have to put mutation and query in correct order. So, mutation dose not update query. It is pain in an ass. Due not full one update, but multiple updates, it breaks, and re-renders your input value. So browsers dose what it has to do, places cursor at end of input. This is why apollo-client is soooo sloooow. Multiple unnecessary updates since version 0 (Still not fixed), but nobody cares about it. As you can charge clients for support. My worst decision ever, using apollo products... Should just stick with Facebook stack.

Can someone here put together a small runnable reproduction that shows this happening? That would definitely help get this fixed faster.

This is why apollo-client is soooo sloooow. Multiple unnecessary updates since version 0 (Still not fixed), but nobody cares about it.

@mjasnikovs I disagree - many people care! Is there a specific open issue that you're referring to? Any extra details you can provide (ideally with a reproduction) would be great.

@hwillson https://codesandbox.io/s/j7o67llwl3

Here is an example :)

Type "aaaa" in the bar, then try to insert bbb in the middle of the string like "aabbbaa".

You will get "aabaabb". Because cursor jumps to the end after 1 change. This is due to the value of the input not updating fast enough due to asynchronous state. When you look at the actual renders.

Input gets the new b, but then on render cycle, the value of it doesn't contain it yet, so it writes aaaa, then gets updated through the store and gets aabaa, but then cursor doesn't match with the previous input anymore due to double render.

@hwillson Any updates on this issue? Any chance to have a fix in the near-future?

Maybe this issue gets some love in 2019?

@hwillson Could you please inform us, so we can take proper decision. In current situation, i don't see any other option, i have to switch away from apollo-client. In all my application, none of input fields are working properly. Do this bug has any priority? Any time-frame? Maybe i need to contact sales and request paid support for this issue? Any information would be helpful.

@mjasnikovs and others - this isn't an Apollo Client (or React Apollo) issue. See https://github.com/facebook/react/issues/955 for an explanation of what's happening, and several different approaches for working around this issue. For example, I've forked the repro and added a new Input component that prevents this from happening - see: https://codesandbox.io/s/xpqz5vxwmq

import React, { Component } from "react";

class Input extends Component {
  constructor(props) {
    super(props);
    this.state = {
      isFocused: false,
      currentValue: props.value
    };
    this.handleChange = this.handleChange.bind(this);
    this.handleFocus = this.handleFocus.bind(this);
    this.handleBlur = this.handleBlur.bind(this);
  }

  handleChange(e) {
    this.setState({ currentValue: e.target.value });
    if (this.props.onChange) {
      this.props.onChange(e);
    }
  }

  handleFocus(e) {
    this.setState({ isFocused: true });
    if (this.props.onFocus) {
      this.props.onFocus(e);
    }
  }

  handleBlur(e) {
    this.setState({ isFocused: false });
    if (this.props.onBlur) {
      this.props.onBlur(e);
    }
  }

  shouldComponentUpdate(nextProps) {
    if (!this.state.isFocused) {
      this.setState({ currentValue: nextProps.value });
    }
    return true;
  }

  render() {
    return (
      <input
        {...this.props}
        onChange={this.handleChange}
        onFocus={this.handleFocus}
        onBlur={this.handleBlur}
        value={this.state.currentValue}
      />
    );
  }
}

export default Input;

@hwilson Thanks for the update. I've forked the sandbox again and eliminated all the TodoList cruft to isolate this issue. Here's what I'm seeing in https://codesandbox.io/s/o54949x296:

  1. Type a into the input.
  2. The onChange handler (and its mutate function) is called.
  3. The UPDATE_TEXT <Mutation /> render prop function is executed with loading equal to true and data equal to undefined. The input then renders with an empty string because the cache supplies us no value.
  4. The updateText mutation resolver is executed and the cache is updated with the new text value, a.
  5. The UPDATE_TEXT <Mutation /> render prop function is executed again, this time with loading equal to false and data containing the updated text value, a. The input renders with the updated value, but because the virtual DOM thinks the input's value is an empty string, the cursor is moved to the end of the input.

It's unfortunate that a local mutation is still treated as an asynchronous operation, with two passes to the render prop function. Since each pass supplies a different data value, react#955 comes into play. Is there any way to remedy this?

@dcapo I used your fork and created "InputGuard" class component wrapper. It hooks in to value and onChange props of children component. To protect from undesired results.

https://codesandbox.io/s/m51zmv37wy

I don't know much about class components as i use only functional components.

Maybe somebody could ta a look and provide some feedback, and together we could find less painful solution. As this issue with apollo-client will be ignored... :confused:

@mjasnikovs We're definitely not ignoring this, it's just that accommodating this directly in Apollo Client / React Apollo might not be the best course of action. I'm happy to re-open this to keep the discussion going, but for now using one of the techniques in https://github.com/facebook/react/issues/955 should help. If anyone has a chance to dig into this further, to see how the Apollo Client / React Apollo internals could be updated to help, that would be great!

I tried to make tests for this problem, but there is issue with browser security. As JavaScrip can't input actual character into input field. Event will fire, but characters will not be inputted. Any helpful input would be appreciated. As i need test, to create proper temp solution for this problem :confused:

Thanks for reporting this. There hasn't been any activity here in quite some time, so we'll close this issue for now. If this is still a problem (using a modern version of Apollo Client), please let us know. Thanks!

@jbaxleyiii this is definitely still an issue. I think it would require a pretty big change to solve this. The issue is due to apollo being asynchronous updates to the store (vs redux for example does a sync update).

The async works really well on most things but not forms, maybe it's about educating people about the issue so they don't use apollo-local-state for input state.

@dozoisch do you have an alternative to recommend? React context? I've been using apollo-local-state for a few other things and it would suck having to introduce a new local state management system because of this... I even tried to make a useMutation that doesn't cause re-renders but same problem still happening:

const useSilentMutation = mutation => {
  const apolloClient = useApolloClient();
  return async opts => {
    return apolloClient.mutate({ mutation, ...opts });
  };
};

So the root issue here seems that the input value doesn't get set in the same event loop as the one onChange event handler is called? If that's correct, the real fix here would be to have a synchronous version of useMutation/apolloClient.update for local state mutations. I don't know why this issue was closed...

Appears that this ugly workaround using the (undocumented?) client.writeData api fixes the problem:

  const client = useApolloClient();

  const onChange = e => {
    const inputValue = e.target.value;
    client.writeData({ data: { inputName: inputValue } });
  }

@olalonde I've switched all our local form state to use bare-bone redux.

Didn't want to introduce new libs either, but that was the simplest solution.

Good day brothers in pain. As there was no way I'm would introduce another state management, just for this one issue. I created a solution to medicate this problem.

Here is gist for wrap component.

https://gist.github.com/mjasnikovs/77a6a48323d14301865d8d94076b335e

<InputGuard>
    <Form.Input
        placeholder="Id"
        autoComplete="off"
        type="text"
        value={uid}
        onChange={updateFormUid}
    />
</InputGuard>

Use it at your own risk :wink:

And don't forget about hooks: https://reactjs.org/docs/hooks-intro.html

This is a React limitation with async / modified setState for the value for controlled text input. The popular solution that worked for me is

var Input = React.createClass({
  render: function() {
    return <input ref="root" {...this.props} value={undefined} />;
  },
  componentDidUpdate: function(prevProps) {
    var node = React.findDOMNode(this);
    var oldLength = node.value.length;
    var oldIdx = node.selectionStart;
    node.value = this.props.value;
    var newIdx = Math.max(0, node.value.length - oldLength + oldIdx);
    node.selectionStart = node.selectionEnd = newIdx;
  },
});

from: https://stackoverflow.com/a/35295650 and https://github.com/facebook/react/issues/955

An alternative is to control the cursor on your onChange handler:

function handleInputValueChange(e) {

    var cursorStart = e.target.selectionStart,
        cursorEnd = e.target.selectionEnd;

        // value manipulations...

    e.target.setSelectionRange(cursorStart, cursorEnd);
}

from: http://dimafeldman.com/js/maintain-cursor-position-after-changing-an-input-value-programatically/

Let me know if this works. If so, it would be great to include this in the Apollo docs in the mutation section, as input text is fairly common, and necessary if we are to follow React's recommended use of controlled components. I'm guessing that's why @rglover used uncontrolled components for the forms in Clever Beagle Pup.
https://github.com/cleverbeagle/pup/blob/812fcb0a2ceb9c860c8f5933469164b34de80f5b/ui/components/DocumentEditor/index.js#L145

@hwillson thanks, this solution works for nextjs / materialui inputs too

Was this page helpful?
0 / 5 - 0 ratings