Summary
Locator.fill() assigns the input's value and dispatches input/change, where Playwright performs a real edit that emits beforeinput/input. React only fires onChange when its internal _valueTracker observes a value different from the one it last recorded, and a direct assignment to .value updates that tracker silently. The result is that React controlled components never see the edit: the DOM shows the typed text, but component state stays empty.
type() and press_sequentially() are unaffected — both emit real key events and React updates correctly. Only fill() is affected.
Repro
Deterministic, local, no network. This emulates React's _valueTracker the same way React does, so it needs no React dependency.
import asyncio, http.server, socket, sys, threading
PAGE = b"""<!doctype html><html><body style="margin:0">
<input id="inp" style="width:200px">
<script>
const inp = document.getElementById('inp');
// emulate React's _valueTracker: stash the last known value, and treat an
// event as a real change only when the current value differs from it.
(function track(node) {
const desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
let current = node.value;
Object.defineProperty(node, 'value', {
get() { return desc.get.call(this); },
set(v) { current = String(v); desc.set.call(this, v); },
configurable: true,
});
window.__reactSawChange = false;
node.addEventListener('input', function () {
if (desc.get.call(node) !== current) { window.__reactSawChange = true; }
current = desc.get.call(node);
});
})(inp);
</script></body></html>"""
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200); self.send_header("Content-Length", str(len(PAGE))); self.end_headers()
self.wfile.write(PAGE)
def log_message(self, *a): pass
def serve():
s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close()
threading.Thread(target=http.server.ThreadingHTTPServer(("127.0.0.1", p), H).serve_forever, daemon=True).start()
return p
async def run(module, port):
api = __import__(f"{module}.async_api", fromlist=["async_playwright"])
async with api.async_playwright() as pw:
b = await pw.chromium.launch(headless=True)
page = await b.new_page()
await page.goto(f"http://127.0.0.1:{port}/")
await page.locator("#inp").fill("hello.py")
saw = await page.evaluate("() => window.__reactSawChange")
val = await page.evaluate("() => document.getElementById('inp').value")
print(f"{module:11s} value={val!r} reactSawChange={saw}")
await b.close()
PORT = serve()
for m in sys.argv[1:] or ["playwright", "rustwright"]:
asyncio.run(run(m, PORT))
Result
playwright value='hello.py' reactSawChange=True
rustwright value='hello.py' reactSawChange=False
Expected: rustwright matches playwright (reactSawChange=True).
Emitted events, same page:
| engine |
events from fill() |
| playwright |
beforeinput, input |
| rustwright |
input, change |
Environment
- rustwright 0.1.1 (PyPI) and git
ec130a64 — both reproduce
- playwright 1.61.0 for comparison
- Chromium headless shell 1228, Linux x86_64, Python 3.14
Why it matters
Found while evaluating rustwright as a drop-in for a Playwright suite covering a React app. Every fill() into a controlled input leaves component state stale, so the UI does not react to input that is visibly present in the DOM. In our case a dialog's confirm button stays disabled with the filename typed in:
after fill: inputValue='zz_probe.py' buttons=[{'text': 'Cancel', 'disabled': False},
{'text': 'Create file', 'disabled': True}]
The failure is silent — fill() returns successfully and the value is readable — so it surfaces much later as an unrelated timeout on whatever the state change was supposed to enable.
Switching the call to press_sequentially() fixes it, which is the workaround we applied, but fill() is the idiomatic API and the one most existing suites use.
Summary
Locator.fill()assigns the input's value and dispatchesinput/change, where Playwright performs a real edit that emitsbeforeinput/input. React only firesonChangewhen its internal_valueTrackerobserves a value different from the one it last recorded, and a direct assignment to.valueupdates that tracker silently. The result is that React controlled components never see the edit: the DOM shows the typed text, but component state stays empty.type()andpress_sequentially()are unaffected — both emit real key events and React updates correctly. Onlyfill()is affected.Repro
Deterministic, local, no network. This emulates React's
_valueTrackerthe same way React does, so it needs no React dependency.Result
Expected: rustwright matches playwright (
reactSawChange=True).Emitted events, same page:
fill()beforeinput,inputinput,changeEnvironment
ec130a64— both reproduceWhy it matters
Found while evaluating rustwright as a drop-in for a Playwright suite covering a React app. Every
fill()into a controlled input leaves component state stale, so the UI does not react to input that is visibly present in the DOM. In our case a dialog's confirm button staysdisabledwith the filename typed in:The failure is silent —
fill()returns successfully and the value is readable — so it surfaces much later as an unrelated timeout on whatever the state change was supposed to enable.Switching the call to
press_sequentially()fixes it, which is the workaround we applied, butfill()is the idiomatic API and the one most existing suites use.