Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,20 @@ open class DefaultElement(
}

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

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

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

// Actually remove the character from the input value and get remaining length
remaining = apply<Int>(
"""
(el) => {
el.value = el.value.slice(1);
el.dispatchEvent(new Event('input', { bubbles: true }));
return el.value.length;
}
""".trimIndent()
) ?: 0
// Read remaining length — the native keyDown already handled deletion
// and React notification; no JS value mutation needed here
remaining = apply<Int>("(el) => el.value.length") ?: 0

// Random delay between deletions (50-100ms) for natural variation
if (remaining > 0) tab.sleep(Random.nextLong(50, 100))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package dev.kdriver.core.dom

import dev.kdriver.core.browser.createBrowser
import dev.kdriver.core.sampleFile
import dev.kdriver.core.tab.ReadyState
import dev.kdriver.core.tab.evaluate
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals

/**
* Tests that reproduce the React controlled input silent failure bug.
*
* React installs an instance-level value property setter on controlled inputs
* (its _valueTracker mechanism). Direct `el.value = x` assignments go through
* this setter, updating the tracker's "last known value". When an 'input' event
* then fires, React checks `el.value === tracker.getValue()` — they match —
* so React concludes nothing changed and does NOT call onChange.
*
* kdriver's clearInput() and clearInputByDeleting() both use direct `.value`
* assignments, making them silently ineffective against React controlled inputs.
* The real-world consequence is a "mixed value" when filling a pre-filled input
* (e.g. old value "10", new value "25" → result "1025").
*/
class ReactControlledInputTest {

/**
* clearInput() uses `element.value = ""` which goes through React's tracker setter,
* updating both the DOM and trackerValue to "". No 'input' event is dispatched,
* so React's onChange check never runs. React state stays at "10".
*/
@Test
fun testClearInputDoesNotNotifyReact() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
val tab = browser.get(sampleFile("react-controlled-input-test.html"))
tab.waitForReadyState(ReadyState.COMPLETE)

val input = tab.select("#controlled-input")

assertEquals("10", tab.evaluate<String>("document.getElementById('state-value').textContent"))
assertEquals("0", tab.evaluate<String>("document.getElementById('change-count').textContent"))

input.clearInput()
delay(100)

// Expected: React state is "" (the clear was communicated to React)
// Actual: React state is still "10" (React was never notified)
assertEquals("", tab.evaluate<String>("document.getElementById('state-value').textContent"))

browser.stop()
}

/**
* clearInputByDeleting() uses `el.value = el.value.slice(1)` on each iteration.
* Each direct .value assignment goes through React's tracker setter, updating both
* the DOM and trackerValue simultaneously. When the 'input' event fires afterwards,
* React checks `el.value === trackerValue` — they match — so onChange is never called.
* React state stays at "10".
*/
@Test
fun testClearInputByDeletingDoesNotNotifyReact() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
val tab = browser.get(sampleFile("react-controlled-input-test.html"))
tab.waitForReadyState(ReadyState.COMPLETE)

val input = tab.select("#controlled-input")

assertEquals("10", tab.evaluate<String>("document.getElementById('state-value').textContent"))
assertEquals("0", tab.evaluate<String>("document.getElementById('change-count').textContent"))

input.clearInputByDeleting()
delay(100)

// Expected: React state is "" (every deletion was communicated to React)
// Actual: React state is still "10" (silent failure — tracker matched on each event)
assertEquals("", tab.evaluate<String>("document.getElementById('state-value').textContent"))

browser.stop()
}

/**
* The real-world consequence: after clearInputByDeleting silently fails to notify React,
* React's async scheduler re-renders and restores the DOM to its controlled value ("10").
* insertText("25") then inserts into "10" instead of an empty field, producing a mixed
* value like "1025" instead of the intended "25".
*/
@Test
fun testFillReactControlledInputProducesMixedValue() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
val tab = browser.get(sampleFile("react-controlled-input-test.html"))
tab.waitForReadyState(ReadyState.COMPLETE)

val input = tab.select("#controlled-input")

// Step 1: Try to clear — silently fails at React level, DOM appears empty
input.clearInputByDeleting()

// Step 2: Simulate React's async re-render committing the old controlled state.
// In a real app this happens automatically (React's scheduler) between operations.
// React uses the native prototype setter to revert the DOM to "10".
tab.evaluate<String>("window.simulateReactRerender();'done'")
delay(50)

// Step 3: Insert the new value — but DOM now holds "10", not ""
input.insertText("25")
delay(100)

// Expected: "25" (clear worked, so inserting into empty field gives "25")
// Actual: "1025" (inserted at end of "10" that React restored)
assertEquals("25", input.getInputValue())

browser.stop()
}

}
86 changes: 86 additions & 0 deletions core/src/jvmTest/resources/react-controlled-input-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<head>
<title>React Controlled Input Test</title>
<script>
window.onload = function () {
const input = document.getElementById('controlled-input');
const stateDisplay = document.getElementById('state-value');
const changeCount = document.getElementById('change-count');

// Simulated React state
let reactState = '10';
let changeCallCount = 0;

// Save the native prototype getter/setter before installing the tracker.
const nativeDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
const nativeSetter = nativeDescriptor.set;
const nativeGetter = nativeDescriptor.get;

// React's _valueTracker: remembers the last value that went through the
// instance-level setter so React can tell whether a change is genuinely new.
let trackerValue;

// Install React's instance-level value property on this specific DOM node.
// This is exactly what React does internally when it first renders a controlled input.
// Direct assignments (el.value = x) go through this setter, updating the tracker.
Object.defineProperty(input, 'value', {
configurable: true,
get: function () {
return nativeGetter.call(this);
},
set: function (val) {
trackerValue = '' + val; // tracker records the assignment
nativeSetter.call(this, val); // actual DOM value update
}
});

// Set the initial controlled value through the tracker (as React does on commit).
input.value = reactState; // → trackerValue = "10", DOM = "10"

// React's change detection: fires onChange only when the new DOM value
// differs from what the tracker last recorded.
input.addEventListener('input', function () {
const currentDOMValue = nativeGetter.call(input);

if (currentDOMValue === trackerValue) {
// DOM value matches tracker → React concludes "I already knew about this"
// → onChange is NOT fired → state remains unchanged
return;
}

// Values differ → genuine change detected → fire onChange
changeCallCount++;
reactState = currentDOMValue;
trackerValue = currentDOMValue;
stateDisplay.textContent = reactState;
changeCount.textContent = String(changeCallCount);
});

stateDisplay.textContent = reactState;
changeCount.textContent = '0';

// Simulates React committing a re-render with the current controlled state.
// In a real React app, the async scheduler does this between user operations.
// Uses the native prototype setter directly (bypassing the tracker instance property),
// which is exactly what React's commit phase does.
window.simulateReactRerender = function () {
nativeSetter.call(input, reactState);
trackerValue = reactState;
};
};
</script>
</head>
<body>
<h1>React Controlled Input Simulation</h1>
<p>Simulates a React-controlled input to reproduce the _valueTracker silent failure bug.</p>
<div>
<label for="controlled-input">Price:</label>
<input id="controlled-input" type="text"/>
</div>
<div>
<p>React state: <span id="state-value">10</span></p>
<p>onChange calls: <span id="change-count">0</span></p>
</div>
</body>
</html>