diff --git a/packages/desktop/e2e/browser-fill.electron.mjs b/packages/desktop/e2e/browser-fill.electron.mjs
new file mode 100644
index 0000000000..78ce935012
--- /dev/null
+++ b/packages/desktop/e2e/browser-fill.electron.mjs
@@ -0,0 +1,108 @@
+import { build } from "esbuild";
+import { fileURLToPath } from "node:url";
+import { expect } from "@playwright/test";
+
+export async function buildFillFixture() {
+ const { outputFiles } = await build({
+ stdin: {
+ resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
+ contents: `
+ import { createElement as e, useState } from 'react';
+ import { createRoot } from 'react-dom/client';
+ function Form() {
+ const [title, setTitle] = useState('');
+ const [notes, setNotes] = useState('');
+ const [submitted, setSubmitted] = useState(null);
+ return e('form', { onSubmit: ev => {
+ ev.preventDefault(); setSubmitted({ title, notes });
+ } },
+ e('input', { 'aria-label': 'React title', value: title,
+ onChange: ev => setTitle(ev.target.value) }),
+ e('textarea', { 'aria-label': 'React notes', value: notes,
+ onChange: ev => setNotes(ev.target.value) }),
+ e('p', null, 'React state: ' + JSON.stringify({ title, notes })),
+ e('button', { type: 'submit' }, 'Submit'),
+ e('p', null, 'Submitted: ' + JSON.stringify(submitted)));
+ }
+ createRoot(document.getElementById('root')).render(e(Form));
+ document.getElementById('plain').addEventListener('input', ev => {
+ document.getElementById('plain-state').textContent = 'Plain state: ' + ev.target.value;
+ });
+ `,
+ },
+ bundle: true,
+ write: false,
+ format: "iife",
+ platform: "browser",
+ define: { "process.env.NODE_ENV": '"production"' },
+ });
+ return `
React fill fixture
+
+
+ `;
+}
+
+async function findRef(call, browserId, role, name) {
+ const { snapshot } = await call("browser_snapshot", { browserId });
+ const line = snapshot.split("\n").find((candidate) => candidate.includes(`${role} "${name}"`));
+ const ref = line?.match(/\[ref=([^\]]+)\]/)?.[1];
+ expect(ref, `Snapshot must expose ${role} ${name}: ${snapshot}`).toBeTruthy();
+ return ref;
+}
+
+async function fillField(call, browserId, name, value) {
+ const ref = await findRef(call, browserId, "textbox", name);
+ await call("browser_fill", { browserId, ref, value });
+}
+
+async function submitForm(call, browserId) {
+ const ref = await findRef(call, browserId, "button", "Submit");
+ await call("browser_click", { browserId, ref });
+}
+
+async function expectPageText(call, browserId, text) {
+ await expect
+ .poll(async () => {
+ const result = await call("browser_evaluate", {
+ browserId,
+ function: "() => document.body.innerText",
+ });
+ return JSON.parse(result.resultJson);
+ })
+ .toContain(text);
+}
+
+export async function verifyReactFormFill(call, targetUrl) {
+ const { browserId } = await call("browser_new_tab", {
+ url: new URL("/react-fill", targetUrl).href,
+ });
+ try {
+ await call("browser_wait", { browserId, text: "React state:", timeoutMs: 15_000 });
+ await fillField(call, browserId, "Plain input", "plain value");
+ await expectPageText(call, browserId, "Plain state: plain value");
+ await fillField(call, browserId, "Editable text", "editable value");
+ await expectPageText(call, browserId, "editable value");
+ await fillField(call, browserId, "React title", "Hello from browser_fill");
+ await fillField(call, browserId, "React notes", "First line\nSecond line");
+ await expectPageText(
+ call,
+ browserId,
+ 'React state: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
+ );
+ await submitForm(call, browserId);
+ await expectPageText(
+ call,
+ browserId,
+ 'Submitted: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
+ );
+ await fillField(call, browserId, "React title", "replacement");
+ await fillField(call, browserId, "React notes", "");
+ await submitForm(call, browserId);
+ await expectPageText(call, browserId, 'Submitted: {"title":"replacement","notes":""}');
+ await fillField(call, browserId, "React title", "");
+ await submitForm(call, browserId);
+ await expectPageText(call, browserId, 'Submitted: {"title":"","notes":""}');
+ } finally {
+ await call("browser_close_tab", { browserId });
+ }
+}
diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs
index 93e6bd61d1..668d884b80 100644
--- a/packages/desktop/e2e/browser-tabs.e2e.mjs
+++ b/packages/desktop/e2e/browser-tabs.e2e.mjs
@@ -17,6 +17,7 @@ import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
+import { buildFillFixture, verifyReactFormFill } from "./browser-fill.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs";
@@ -216,7 +217,13 @@ async function settleDesktopStartup(page) {
const CSP_PAGE_PATH = "/csp-no-eval";
async function startTargetPage() {
+ const fillFixture = await buildFillFixture();
const server = createServer((request, response) => {
+ if (request.url === "/react-fill") {
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
+ response.end(fillFixture);
+ return;
+ }
if (request.url === CSP_PAGE_PATH) {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
@@ -782,6 +789,7 @@ async function runRegression({
document.body.appendChild(overlayRoot);
});
+ await verifyReactFormFill((name, args) => callBrowserTool(client, name, args), targetUrl);
await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl);
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
diff --git a/packages/desktop/src/features/browser-automation/snapshot-engine.ts b/packages/desktop/src/features/browser-automation/snapshot-engine.ts
index 2ee1a68a5e..71a688c7b2 100644
--- a/packages/desktop/src/features/browser-automation/snapshot-engine.ts
+++ b/packages/desktop/src/features/browser-automation/snapshot-engine.ts
@@ -316,7 +316,12 @@ function buildFillScript(metadata: BrowserRefMetadata, value: string): string {
element.focus();
const nextValue = ${JSON.stringify(value)};
if ('value' in element) {
- element.value = nextValue;
+ const view = element.ownerDocument.defaultView;
+ const prototype = element.tagName === 'INPUT' ? view.HTMLInputElement.prototype
+ : element.tagName === 'TEXTAREA' ? view.HTMLTextAreaElement.prototype : null;
+ // Bypass instance setters so framework value trackers observe the input event as a change.
+ if (prototype) Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, nextValue);
+ else element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };