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
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.
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.