banner
sora

sora

编程心得 & 人文感悟

Why is setting the value of a React input box directly not working?

Background#

Recently, I wanted to write a Tampermonkey script to automate filling out forms. However, directly modifying the corresponding value of the node did not work. After refocusing, the original value would be set back. Why is this happening?

Irrelevant image
My friend is sweating profusely

Process#

Considering that the React framework is widely used and the input component is fundamental, maybe this problem has already been solved. So I decided to search for a solution.

image

First, I checked Blog Garden and a notorious four-letter code stealing website, but there were no reliable methods. No matter what event I triggered, it didn't work. Except for a webpage that called the _valueTracker property under React development, which worked.

I tried it, but it didn't work.

But for such a problem, I don't think simulating keyboard input is necessary.

Although I didn't solve the problem, at least I know that the webpage is built using the React framework. Besides this lucky method, I can also use the Wappalyzer plugin, but it requires payment to unlock all its capabilities. However, I think manually checking the <head> is also feasible.

This basic problem shouldn't be unsolvable. I tried my luck by checking the React source code and found the solution in the unit test of the input component. I have to say that large open-source projects have more comments than code, and the file names are clear and easy to understand. They are really standardized and beautiful.

// https://github.com/facebook/react/blob/main/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js#L273
const setUntrackedValue = Object.getOwnPropertyDescriptor(
  HTMLInputElement.prototype,
  'value',
).set;

...

    // Set it programmatically.
    input.value = 'bar';
    // Even if a DOM input event fires, React sees that the real input value now
    // ('bar') is the same as the "current" one we already recorded.
    input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
    expect(input.value).toBe('bar');
    // In this case we don't expect to get a React event.
    expect(called).toBe(0);

    // However, we can simulate user typing by calling the underlying setter.
    setUntrackedValue.call(input, 'foo');
    // Now, when the event fires, the real input value ('foo') differs from the
    // "current" one we previously recorded ('bar').
    input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
    expect(input.value).toBe('foo');
    // In this case React should fire an event for it.
    expect(called).toBe(1);

Reason#

So, why doesn't directly setting the value attribute work? We know that JavaScript has been criticized for a reason. What happened to our modern Lisp? It can implement various fancy operations. Various variables are mixed together, and there were almost no scope restrictions before ES6. It was easy to modify the properties of other code, or even replace undefined. A famous evil repository used this to poison the supply chain and cause various troubles.

When React encapsulates the ReactDOMInput component, it replaces the descriptor corresponding to the value of the native input component.

// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/inputValueTracking.js#L79
  Object.defineProperty(node, valueField, {
    configurable: true,
    // $FlowFixMe[missing-this-annot]
    get: function () {
      return get.call(this);
    },
    // $FlowFixMe[missing-local-annot]
    // $FlowFixMe[missing-this-annot]
    set: function (value) {
      if (__DEV__) {
        checkFormFieldValueStringCoercion(value);
      }
      currentValue = '' + value;
      set.call(this, value);
    },
  });
  // We could've passed this the first time
  // but it triggers a bug in IE11 and Edge 14/15.
  // Calling defineProperty() again should be equivalent.
  // https://github.com/facebook/react/issues/11768
  Object.defineProperty(node, valueField, {
    enumerable: descriptor.enumerable,
  });

  const tracker = {
    getValue() {
      return currentValue;
    },
    setValue(value: string) {
      if (__DEV__) {
        checkFormFieldValueStringCoercion(value);
      }
      currentValue = '' + value;
    },
    stopTracking() {
      detachTracker(node);
      delete node[valueField];
    },
  };
  return tracker;

The currentValue is responsible for tracking the changes in the input component's value and is the return value of the tracker's getValue method. If the value attribute is set directly, it will go to the set method, which sets both currentValue and calls the native set method.

Because the currentValue tracked by the tracker is the same as the value value inside the native input component, even after triggering the input event, the externally forcibly written value will not change the internal value of React.

// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/inputValueTracking.js
export function updateValueIfChanged(node: ElementWithValueTracker): boolean {
  if (!node) {
    return false;
  }

  const tracker = getTracker(node);
  // if there is no tracker at this point it's unlikely
  // that trying again will succeed
  if (!tracker) {
    return true;
  }

  const lastValue = tracker.getValue(); // Note: currentValue
  const nextValue = getValueFromNode(node); // Note: input.value
  if (nextValue !== lastValue) {
    tracker.setValue(nextValue);
    return true;
  }
  return false;
}

When the input regains focus, restoreControlledInputState in ReactDOMInput is called, and the value forcibly written by the native component is overwritten by the value originally recorded in React.

// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactDOMInput.js#L340
export function restoreControlledInputState(element: Element, props: Object) {
  const rootNode: HTMLInputElement = (element: any);
  updateInput(
    rootNode,
    props.value,
    props.defaultValue,
    props.defaultValue,
    props.checked,
    props.defaultChecked,
    props.type,
    props.name,
  );

    ...

}

So, why add the mechanism of currentValue? Is it because React developers don't like Tampermonkey scripts?

By using git blame, I found the last commit in packages\react-dom\src\events\plugins\__tests__\ChangeEventPlugin-test.js, which was a directory structure reorganization. I can see that there used to be a very detailed comment here, explaining why this mechanism was added.

// https://github.com/facebook/react/commit/a67757e11540936677b0a5d89d623b3e38b5fe69#diff-76a986d539bc7c94d63e46233a9537e03d8994f69ac4e4cd332631bc911a1c09R81

    // We try to avoid firing "duplicate" React change events.
    // However, to tell which events are duplicates and should be ignored,
    // we are tracking the "current" input value, and only respect events
    // that occur after it changes. In this test, we verify that we can
    // keep track of the "current" value even if it is set programatically.

    // Set it programmatically.
    input.value = 'bar';

In 2016, this mechanism only worked for Input components that had the onChange property set. From this comment, we can conclude that this mechanism was initially added to deduplicate change events, so an currentValue was added outside of input.value to avoid triggering the onChange function multiple times.

Thoughts#

Open source has a truly positive impact on the industry and personal growth. When encountering problems, we can search and learn on our own, instead of relying on third-hand information or vague documentation of closed-source software. It feels like punching cotton when spending a lot of time on something copied from others.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.