Skip to content

Locator.fill() is invisible to React controlled inputs (bypasses _valueTracker) #187

Description

@adhami3310

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions