Skip to content

Commit 81ab04c

Browse files
authored
fix: react input issues (#61)
* reproduce the issue * fix the issue
1 parent b0b9298 commit 81ab04c

File tree

3 files changed

+232
-13
lines changed

3 files changed

+232
-13
lines changed

core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,20 @@ open class DefaultElement(
531531
}
532532

533533
override suspend fun clearInput() {
534-
apply<Unit>("function (element) { element.value = \"\" }")
534+
// Use the native prototype setter instead of a direct el.value assignment.
535+
// Direct assignment (el.value = "") goes through React's instance-level tracker
536+
// setter, updating trackerValue and the DOM simultaneously. When an input event
537+
// then fires, React sees el.value === trackerValue and concludes nothing changed,
538+
// so onChange is never called. The native prototype setter bypasses the tracker,
539+
// so the subsequent input event correctly triggers React's onChange.
540+
apply<Unit>(
541+
"""
542+
(el) => {
543+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, '');
544+
el.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true }));
545+
}
546+
""".trimIndent()
547+
)
535548
}
536549

537550
override suspend fun clearInputByDeleting() {
@@ -548,10 +561,21 @@ open class DefaultElement(
548561
) ?: 0
549562

550563
// Delete each character using CDP Input.dispatchKeyEvent (P3 - Anti-detection)
551-
// This generates isTrusted: true events unlike JavaScript KeyboardEvent dispatch
564+
// This generates isTrusted: true events unlike JavaScript KeyboardEvent dispatch.
565+
//
566+
// CDP keyDown with key="Delete" at cursor position 0 uses the browser's native
567+
// text-editing pipeline, which bypasses React's instance-level tracker setter.
568+
// This means React sees a mismatch between el.value and trackerValue and correctly
569+
// fires onChange — unlike a direct el.value assignment which updates both
570+
// simultaneously, causing React to silently ignore the change.
571+
//
572+
// Cursor stays at position 0 across iterations: forward-delete (VK_DELETE=46)
573+
// removes the character at the cursor without moving it, so each keyDown always
574+
// operates at the start of the remaining text.
552575
var remaining = initialLength
553576
while (remaining > 0) {
554-
// Dispatch keydown event
577+
// Dispatch keydown event — this natively deletes one character and fires
578+
// a real input event that React's change detection processes correctly
555579
tab.input.dispatchKeyEvent(
556580
type = "keyDown",
557581
key = "Delete",
@@ -569,16 +593,9 @@ open class DefaultElement(
569593
nativeVirtualKeyCode = 46
570594
)
571595

572-
// Actually remove the character from the input value and get remaining length
573-
remaining = apply<Int>(
574-
"""
575-
(el) => {
576-
el.value = el.value.slice(1);
577-
el.dispatchEvent(new Event('input', { bubbles: true }));
578-
return el.value.length;
579-
}
580-
""".trimIndent()
581-
) ?: 0
596+
// Read remaining length — the native keyDown already handled deletion
597+
// and React notification; no JS value mutation needed here
598+
remaining = apply<Int>("(el) => el.value.length") ?: 0
582599

583600
// Random delay between deletions (50-100ms) for natural variation
584601
if (remaining > 0) tab.sleep(Random.nextLong(50, 100))
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)