Skip to content

Commit 5dabacf

Browse files
committed
reproduce the issue
1 parent b0b9298 commit 5dabacf

File tree

2 files changed

+202
-0
lines changed

2 files changed

+202
-0
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package dev.kdriver.core.dom
2+
3+
import dev.kdriver.core.browser.createBrowser
4+
import dev.kdriver.core.sampleFile
5+
import dev.kdriver.core.tab.ReadyState
6+
import dev.kdriver.core.tab.evaluate
7+
import kotlinx.coroutines.delay
8+
import kotlinx.coroutines.runBlocking
9+
import kotlin.test.Test
10+
import kotlin.test.assertEquals
11+
12+
/**
13+
* Tests that reproduce the React controlled input silent failure bug.
14+
*
15+
* React installs an instance-level value property setter on controlled inputs
16+
* (its _valueTracker mechanism). Direct `el.value = x` assignments go through
17+
* this setter, updating the tracker's "last known value". When an 'input' event
18+
* then fires, React checks `el.value === tracker.getValue()` — they match —
19+
* so React concludes nothing changed and does NOT call onChange.
20+
*
21+
* kdriver's clearInput() and clearInputByDeleting() both use direct `.value`
22+
* assignments, making them silently ineffective against React controlled inputs.
23+
* The real-world consequence is a "mixed value" when filling a pre-filled input
24+
* (e.g. old value "10", new value "25" → result "1025").
25+
*/
26+
class ReactControlledInputTest {
27+
28+
/**
29+
* clearInput() uses `element.value = ""` which goes through React's tracker setter,
30+
* updating both the DOM and trackerValue to "". No 'input' event is dispatched,
31+
* so React's onChange check never runs. React state stays at "10".
32+
*/
33+
@Test
34+
fun testClearInputDoesNotNotifyReact() = runBlocking {
35+
val browser = createBrowser(this, headless = true, sandbox = false)
36+
val tab = browser.get(sampleFile("react-controlled-input-test.html"))
37+
tab.waitForReadyState(ReadyState.COMPLETE)
38+
39+
val input = tab.select("#controlled-input")
40+
41+
assertEquals("10", tab.evaluate<String>("document.getElementById('state-value').textContent"))
42+
assertEquals("0", tab.evaluate<String>("document.getElementById('change-count').textContent"))
43+
44+
input.clearInput()
45+
delay(100)
46+
47+
// Expected: React state is "" (the clear was communicated to React)
48+
// Actual: React state is still "10" (React was never notified)
49+
assertEquals("", tab.evaluate<String>("document.getElementById('state-value').textContent"))
50+
51+
browser.stop()
52+
}
53+
54+
/**
55+
* clearInputByDeleting() uses `el.value = el.value.slice(1)` on each iteration.
56+
* Each direct .value assignment goes through React's tracker setter, updating both
57+
* the DOM and trackerValue simultaneously. When the 'input' event fires afterwards,
58+
* React checks `el.value === trackerValue` — they match — so onChange is never called.
59+
* React state stays at "10".
60+
*/
61+
@Test
62+
fun testClearInputByDeletingDoesNotNotifyReact() = runBlocking {
63+
val browser = createBrowser(this, headless = true, sandbox = false)
64+
val tab = browser.get(sampleFile("react-controlled-input-test.html"))
65+
tab.waitForReadyState(ReadyState.COMPLETE)
66+
67+
val input = tab.select("#controlled-input")
68+
69+
assertEquals("10", tab.evaluate<String>("document.getElementById('state-value').textContent"))
70+
assertEquals("0", tab.evaluate<String>("document.getElementById('change-count').textContent"))
71+
72+
input.clearInputByDeleting()
73+
delay(100)
74+
75+
// Expected: React state is "" (every deletion was communicated to React)
76+
// Actual: React state is still "10" (silent failure — tracker matched on each event)
77+
assertEquals("", tab.evaluate<String>("document.getElementById('state-value').textContent"))
78+
79+
browser.stop()
80+
}
81+
82+
/**
83+
* The real-world consequence: after clearInputByDeleting silently fails to notify React,
84+
* React's async scheduler re-renders and restores the DOM to its controlled value ("10").
85+
* insertText("25") then inserts into "10" instead of an empty field, producing a mixed
86+
* value like "1025" instead of the intended "25".
87+
*/
88+
@Test
89+
fun testFillReactControlledInputProducesMixedValue() = runBlocking {
90+
val browser = createBrowser(this, headless = true, sandbox = false)
91+
val tab = browser.get(sampleFile("react-controlled-input-test.html"))
92+
tab.waitForReadyState(ReadyState.COMPLETE)
93+
94+
val input = tab.select("#controlled-input")
95+
96+
// Step 1: Try to clear — silently fails at React level, DOM appears empty
97+
input.clearInputByDeleting()
98+
99+
// Step 2: Simulate React's async re-render committing the old controlled state.
100+
// In a real app this happens automatically (React's scheduler) between operations.
101+
// React uses the native prototype setter to revert the DOM to "10".
102+
tab.evaluate<String>("window.simulateReactRerender();'done'")
103+
delay(50)
104+
105+
// Step 3: Insert the new value — but DOM now holds "10", not ""
106+
input.insertText("25")
107+
delay(100)
108+
109+
// Expected: "25" (clear worked, so inserting into empty field gives "25")
110+
// Actual: "1025" (inserted at end of "10" that React restored)
111+
assertEquals("25", input.getInputValue())
112+
113+
browser.stop()
114+
}
115+
116+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>React Controlled Input Test</title>
5+
<script>
6+
window.onload = function () {
7+
const input = document.getElementById('controlled-input');
8+
const stateDisplay = document.getElementById('state-value');
9+
const changeCount = document.getElementById('change-count');
10+
11+
// Simulated React state
12+
let reactState = '10';
13+
let changeCallCount = 0;
14+
15+
// Save the native prototype getter/setter before installing the tracker.
16+
const nativeDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
17+
const nativeSetter = nativeDescriptor.set;
18+
const nativeGetter = nativeDescriptor.get;
19+
20+
// React's _valueTracker: remembers the last value that went through the
21+
// instance-level setter so React can tell whether a change is genuinely new.
22+
let trackerValue;
23+
24+
// Install React's instance-level value property on this specific DOM node.
25+
// This is exactly what React does internally when it first renders a controlled input.
26+
// Direct assignments (el.value = x) go through this setter, updating the tracker.
27+
Object.defineProperty(input, 'value', {
28+
configurable: true,
29+
get: function () {
30+
return nativeGetter.call(this);
31+
},
32+
set: function (val) {
33+
trackerValue = '' + val; // tracker records the assignment
34+
nativeSetter.call(this, val); // actual DOM value update
35+
}
36+
});
37+
38+
// Set the initial controlled value through the tracker (as React does on commit).
39+
input.value = reactState; // → trackerValue = "10", DOM = "10"
40+
41+
// React's change detection: fires onChange only when the new DOM value
42+
// differs from what the tracker last recorded.
43+
input.addEventListener('input', function () {
44+
const currentDOMValue = nativeGetter.call(input);
45+
46+
if (currentDOMValue === trackerValue) {
47+
// DOM value matches tracker → React concludes "I already knew about this"
48+
// → onChange is NOT fired → state remains unchanged
49+
return;
50+
}
51+
52+
// Values differ → genuine change detected → fire onChange
53+
changeCallCount++;
54+
reactState = currentDOMValue;
55+
trackerValue = currentDOMValue;
56+
stateDisplay.textContent = reactState;
57+
changeCount.textContent = String(changeCallCount);
58+
});
59+
60+
stateDisplay.textContent = reactState;
61+
changeCount.textContent = '0';
62+
63+
// Simulates React committing a re-render with the current controlled state.
64+
// In a real React app, the async scheduler does this between user operations.
65+
// Uses the native prototype setter directly (bypassing the tracker instance property),
66+
// which is exactly what React's commit phase does.
67+
window.simulateReactRerender = function () {
68+
nativeSetter.call(input, reactState);
69+
trackerValue = reactState;
70+
};
71+
};
72+
</script>
73+
</head>
74+
<body>
75+
<h1>React Controlled Input Simulation</h1>
76+
<p>Simulates a React-controlled input to reproduce the _valueTracker silent failure bug.</p>
77+
<div>
78+
<label for="controlled-input">Price:</label>
79+
<input id="controlled-input" type="text"/>
80+
</div>
81+
<div>
82+
<p>React state: <span id="state-value">10</span></p>
83+
<p>onChange calls: <span id="change-count">0</span></p>
84+
</div>
85+
</body>
86+
</html>

0 commit comments

Comments
 (0)