diff --git a/crates/obscura-cdp/src/domains/input.rs b/crates/obscura-cdp/src/domains/input.rs index db690b2..4b001aa 100644 --- a/crates/obscura-cdp/src/domains/input.rs +++ b/crates/obscura-cdp/src/domains/input.rs @@ -14,12 +14,12 @@ fn insert_text_js(escaped_text: &str) -> String { var v = t.value || '';\ var s = t.selectionStart, e = t.selectionEnd;\ if (s == null) {{\ - t.value = v + '{text}';\ + globalThis.__obscura_setFieldValue(t, 'value', v + '{text}');\ }} else {{\ s = Math.max(0, Math.min(s, v.length));\ e = (e == null) ? s : Math.max(0, Math.min(e, v.length));\ var lo = Math.min(s, e), hi = Math.max(s, e);\ - t.value = v.slice(0, lo) + '{text}' + v.slice(hi);\ + globalThis.__obscura_setFieldValue(t, 'value', v.slice(0, lo) + '{text}' + v.slice(hi));\ var caret = lo + ('{text}').length;\ t.setSelectionRange(caret, caret);\ }}\ @@ -39,16 +39,16 @@ const BACKSPACE_JS: &str = "(function() {\ var v = t.value || '';\ var s = t.selectionStart, e = t.selectionEnd;\ if (s == null) {\ - t.value = v.slice(0, -1);\ + globalThis.__obscura_setFieldValue(t, 'value', v.slice(0, -1));\ } else {\ s = Math.max(0, Math.min(s, v.length));\ e = (e == null) ? s : Math.max(0, Math.min(e, v.length));\ if (s !== e) {\ var lo = Math.min(s, e), hi = Math.max(s, e);\ - t.value = v.slice(0, lo) + v.slice(hi);\ + globalThis.__obscura_setFieldValue(t, 'value', v.slice(0, lo) + v.slice(hi));\ t.setSelectionRange(lo, lo);\ } else if (s > 0) {\ - t.value = v.slice(0, s - 1) + v.slice(s);\ + globalThis.__obscura_setFieldValue(t, 'value', v.slice(0, s - 1) + v.slice(s));\ t.setSelectionRange(s - 1, s - 1);\ }\ }\ @@ -173,7 +173,7 @@ pub async fn handle( if (!target) return;\ target.dispatchEvent(globalThis.__obscura_markTrusted(new KeyboardEvent('keypress', {bubbles:true,key:'Enter',code:'Enter'})));\ if (target.localName === 'textarea') {\ - target.value = (target.value || '') + '\\n';\ + globalThis.__obscura_setFieldValue(target, 'value', (target.value || '') + '\\n');\ target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {bubbles:true})));\ } else {\ var form = target.form || (target.closest && target.closest('form'));\ diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 360abc1..d4681d6 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2646,10 +2646,14 @@ globalThis.frames = globalThis; globalThis.frameElement = null; globalThis.length = 0; -// HTML spec exposes on* event handler IDL attributes on Window. Libraries like -// jQuery feature-detect bubbling via `("on" + ev) in window` and fall back to -// a legacy IE path that crashes on missing DOM APIs when the check returns -// false. Initialising them to null makes the check match real browsers. +// HTML spec exposes on* event handler IDL attributes via the GlobalEventHandlers +// mixin on Window, Document, and HTMLElement. Libraries feature-detect the modern +// event path through these: jQuery checks `("on" + ev) in window`, and React +// decides whether the `input` event is supported via `("oninput" in document)`. +// When that check fails React falls back to a legacy change-detection path that +// never fires onChange for controlled inputs (issue #324). Initialising these to +// null on all three targets makes the checks match real browsers. On Document and +// Element they are non-enumerable so they don't surface in `for..in` over nodes. for (const _ev of [ "abort","beforeprint","beforeunload","blur","cancel","canplay","canplaythrough", "change","click","close","contextmenu","cuechange","dblclick","drag","dragend", @@ -2665,7 +2669,13 @@ for (const _ev of [ "stalled","storage","submit","suspend","timeupdate","toggle","unhandledrejection", "unload","volumechange","waiting","wheel", ]) { - if (!(("on" + _ev) in globalThis)) globalThis["on" + _ev] = null; + const _on = "on" + _ev; + if (!(_on in globalThis)) globalThis[_on] = null; + for (const _proto of [Document.prototype, Element.prototype]) { + if (!(_on in _proto)) { + Object.defineProperty(_proto, _on, { value: null, writable: true, configurable: true, enumerable: false }); + } + } } globalThis.Window = globalThis.Window || function Window() {}; @@ -4286,6 +4296,27 @@ globalThis.DOMException = (function () { // non-enumerable __obscura_markTrusted helper. const _trustedEvents = new WeakSet(); globalThis.__obscura_markTrusted = function(ev) { try { if (ev) _trustedEvents.add(ev); } catch (_e) {} return ev; }; + +// Write value/checked through the element's *prototype* accessor, skipping any +// per-instance property a framework layered on top. React (and Preact/Vue) +// install a value tracker by redefining `value`/`checked` on the element to +// record the last value they wrote; a plain `el.value = x` runs that wrapper, +// so their tracker updates in lockstep and the next input/change event looks +// unchanged, so onChange never fires (issue #324). Writing through the +// prototype setter leaves the tracker stale, so the edit is seen as a real +// user change. When no framework wrapper is present this is identical to a +// direct assignment. +globalThis.__obscura_setFieldValue = function(el, field, value) { + try { + let proto = Object.getPrototypeOf(el); + let desc; + while (proto && !((desc = Object.getOwnPropertyDescriptor(proto, field)) && desc.set)) { + proto = Object.getPrototypeOf(proto); + } + if (desc && desc.set) { desc.set.call(el, value); return; } + } catch (_e) {} + el[field] = value; +}; globalThis.Event = class Event { constructor(t,o={}) { this.type=t;this.bubbles=!!o.bubbles;this.cancelable=!!o.cancelable;this.composed=!!o.composed;this.defaultPrevented=false;this.target=null;this.currentTarget=null;this.eventPhase=0;this.timeStamp=Date.now();this._propagationStopped=false;this._immediatePropagationStopped=false; } get isTrusted() { return _trustedEvents.has(this); } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index ca3304d..4c4696c 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1801,6 +1801,72 @@ mod tests { assert_eq!(checked2, serde_json::json!(false)); } + // Issue #324: React/Preact/Vue install a value tracker by redefining `value` + // on the element instance so they can tell a real edit from their own + // controlled write. __obscura_setFieldValue must write through the prototype + // setter, leaving that per-instance tracker stale, so the following input + // event reads as a genuine change and onChange fires. A plain assignment + // keeps the tracker in sync and suppresses onChange. + #[test] + fn set_field_value_bypasses_instance_value_wrapper() { + let mut rt = setup_runtime(r#""#); + let result = rt + .evaluate( + r#" + (function(){ + var el = document.getElementById('i'); + var d = Object.getOwnPropertyDescriptor(el.constructor.prototype, 'value'); + var set = d.set, get = d.get, tracked = '' + el.value; + Object.defineProperty(el, 'value', { + configurable: true, + get: function(){ return get.call(this); }, + set: function(v){ tracked = '' + v; set.call(this, v); }, + }); + el.value = 'wrapped'; + var afterDirect = { value: el.value, tracked: tracked }; + globalThis.__obscura_setFieldValue(el, 'value', 'native'); + var afterHelper = { value: el.value, tracked: tracked }; + return JSON.stringify({ afterDirect: afterDirect, afterHelper: afterHelper }); + })() + "#, + ) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(result.as_str().unwrap()).unwrap(); + // Direct assignment keeps tracker == value (the change that suppresses onChange). + assert_eq!(parsed["afterDirect"]["value"], "wrapped"); + assert_eq!(parsed["afterDirect"]["tracked"], "wrapped"); + // The helper updates the value but leaves the tracker stale, so onChange fires. + assert_eq!(parsed["afterHelper"]["value"], "native"); + assert_eq!(parsed["afterHelper"]["tracked"], "wrapped"); + } + + // Issue #324: React feature-detects the modern input-event path with + // `('oninput' in document)`. If the GlobalEventHandlers on* attributes are + // only on window (not Document/Element), that check fails and React falls + // back to a legacy change-detection path, so controlled-input onChange never + // fires. These must be present on document and Element.prototype too. + #[test] + fn global_event_handlers_present_on_document_and_element() { + let mut rt = setup_runtime("
"); + let result = rt + .evaluate( + r#"JSON.stringify({ + docInput: ('oninput' in document), + docChange: ('onchange' in document), + docClick: ('onclick' in document), + elProtoInput: ('oninput' in Element.prototype), + winInput: ('oninput' in window) + })"#, + ) + .unwrap(); + let p: serde_json::Value = serde_json::from_str(result.as_str().unwrap()).unwrap(); + assert_eq!(p["docInput"], true); + assert_eq!(p["docChange"], true); + assert_eq!(p["docClick"], true); + assert_eq!(p["elProtoInput"], true); + assert_eq!(p["winInput"], true); + } + #[test] fn test_matches_and_closest() { let mut rt = setup_runtime(r#"
Hi
"#);