|
| 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 | +} |
0 commit comments