fix(cdp): fire onChange for React/Vue controlled inputs (#353)
Typing over CDP into a React controlled input never fired onChange, so the value never reached component state (issue #324). Two root causes, both needed: 1. Value tracker. React/Preact/Vue install a value tracker by redefining `value` on the element instance to record the last value they wrote. obscura's CDP typing assigned `el.value = ...` directly, which runs that wrapper and keeps the tracker in sync, so React saw no change on the input event. Synthetic value/backspace writes now go through a new __obscura_setFieldValue helper that writes via the element's prototype setter, bypassing the tracker. The tracker stays stale, so the input event reads as a real edit. 2. Event handler feature detection. React decides whether to use the modern `input` event via `("oninput" in document)`. obscura exposed the GlobalEventHandlers on* attributes only on window, so that check failed and React fell back to a legacy change-detection path that ignores input events. The on* attributes are now also present on Document and Element, matching the HTML spec (GlobalEventHandlers is mixed into Window, Document, and HTMLElement). Verified end to end over CDP against the real React 18 UMD build: typing into controlled inputs updates state, the component re-renders, and a submit handler reads the captured values. Closes #324.
This commit is contained in:
1 parent
b5039a8a7f
commit
08c1f0d721
3 files changed
+108
-11
No files matched your search
@@ -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'));\
|
||||
|
||||
Vendored
+36
-5
@@ -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); }
|
||||
|
||||
@@ -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#"<input id="i">"#);
|
||||
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("<div></div>");
|
||||
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#"<div class="outer"><div class="inner"><span id="target">Hi</span></div></div>"#);
|
||||
|
||||
Reference in new issue
Block a user