Skip to content

Commit 5680d8d

Browse files
committed
AG-51992 Improve 'trusted-click-element' — regression about handling React HTMLElements. #554
Squashed commit of the following: commit b881e68 Author: Adam Wróblewski <adam@adguard.com> Date: Tue Mar 17 15:56:43 2026 +0100 Improve 'trusted-click-element' scriptlet, add ability to enforce native click, and pass event to reactProps.onClick and reactProps.onFocus
1 parent 9893ccb commit 5680d8d

4 files changed

Lines changed: 391 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
2424

2525
### Changed
2626

27+
- `trusted-click-element` now passes synthetic-like event objects to React handlers
28+
and supports `clickType:native` in `extraMatch` to force native click [#554].
2729
- Added `interceptChainProp` helper to share intermediate chain property access logic
2830
across `abort-on-property-read`, `abort-on-property-write`, `abort-on-stack-trace`,
2931
`abort-current-inline-script`, `debug-on-property-write`, `debug-on-property-read`,
@@ -48,6 +50,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
4850
[#545]: https://github.com/AdguardTeam/Scriptlets/issues/545
4951
[#549]: https://github.com/AdguardTeam/Scriptlets/issues/549
5052
[#552]: https://github.com/AdguardTeam/Scriptlets/issues/552
53+
[#554]: https://github.com/AdguardTeam/Scriptlets/issues/554
5154

5255
## [v2.2.16] - 2026-02-19
5356

src/helpers/click-utils.ts

Lines changed: 211 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -188,29 +188,11 @@ export const bridgeIframeLoads = (nodes: NodeList) => {
188188
* so we need to trigger React's synthetic event handlers directly.
189189
*
190190
* @param element HTML element to click.
191+
* @param clickType Optional click mode. Use 'native' to bypass React internal handlers.
191192
*/
192-
export const clickElement = (element: HTMLElement): void => {
193+
export const clickElement = (element: HTMLElement, clickType = ''): void => {
193194
const REACT_PROPS_KEY_PREFIX = '__reactProps$';
194-
195-
// Find React internal props key on the element
196-
const reactPropsKey = Object.keys(element).find((key) => key.startsWith(REACT_PROPS_KEY_PREFIX));
197-
198-
// If React props are found, try to use React's handlers
199-
if (reactPropsKey) {
200-
const reactProps = (element as unknown as Record<string, unknown>)[reactPropsKey] as {
201-
onFocus?: () => void;
202-
onClick?: () => void;
203-
} | undefined;
204-
205-
if (reactProps && typeof reactProps.onClick === 'function') {
206-
// Call onFocus first if available, as some React components require it
207-
if (typeof reactProps.onFocus === 'function') {
208-
reactProps.onFocus();
209-
}
210-
reactProps.onClick();
211-
return;
212-
}
213-
}
195+
const NATIVE_CLICK_TYPE = 'native';
214196

215197
// Simulate a realistic click because it may not be enough to execute element.click()
216198
// https://github.com/AdguardTeam/Scriptlets/issues/491
@@ -234,23 +216,214 @@ export const clickElement = (element: HTMLElement): void => {
234216
const noBubbleOpts: MouseEventInit = Object.assign({}, commonOpts, { bubbles: false });
235217
const releaseOpts: MouseEventInit = Object.assign({}, commonOpts, { buttons: 0 });
236218

237-
// Feature-detect PointerEvent for environments that don't support it
238-
const hasPointerEvent = typeof PointerEvent === 'function';
219+
/**
220+
* Creates a trusted-looking event proxy for either React handlers or native
221+
* inline handlers.
222+
*
223+
* React handlers need extra SyntheticEvent-like fields such as
224+
* `nativeEvent`, `persist()`, `isDefaultPrevented()` and stable
225+
* `currentTarget`. Native inline handlers only need the original event with
226+
* `isTrusted` spoofed to `true`.
227+
*
228+
* @param nativeEvent Original DOM event.
229+
* @param eventType Event type exposed to the handler.
230+
* @param isReactEvent Whether to expose React-specific SyntheticEvent-like fields.
231+
*
232+
* @returns Proxied event object with the fields needed for the target handler type.
233+
*/
234+
const createEventProxy = (nativeEvent: Event, eventType: string, isReactEvent = false): Event => {
235+
let defaultPrevented = nativeEvent.defaultPrevented;
236+
let propagationStopped = false;
237+
238+
return new Proxy(nativeEvent, {
239+
get(target, prop) {
240+
if (prop === 'isTrusted') {
241+
return true;
242+
}
243+
if (!isReactEvent) {
244+
const value = Reflect.get(target, prop);
245+
if (typeof value === 'function') {
246+
return value.bind(target);
247+
}
239248

240-
if (hasPointerEvent) {
241-
element.dispatchEvent(new PointerEvent('pointerover', commonOpts));
242-
element.dispatchEvent(new PointerEvent('pointerenter', noBubbleOpts));
243-
}
244-
element.dispatchEvent(new MouseEvent('mouseover', commonOpts));
245-
element.dispatchEvent(new MouseEvent('mouseenter', noBubbleOpts));
246-
if (hasPointerEvent) {
247-
element.dispatchEvent(new PointerEvent('pointerdown', commonOpts));
248-
}
249-
element.dispatchEvent(new MouseEvent('mousedown', commonOpts));
250-
element.focus();
251-
if (hasPointerEvent) {
252-
element.dispatchEvent(new PointerEvent('pointerup', releaseOpts));
249+
return value;
250+
}
251+
if (prop === 'nativeEvent') {
252+
return target;
253+
}
254+
if (prop === 'target' || prop === 'srcElement' || prop === 'currentTarget') {
255+
return element;
256+
}
257+
if (prop === 'type') {
258+
return eventType;
259+
}
260+
if (prop === 'defaultPrevented') {
261+
return defaultPrevented;
262+
}
263+
if (prop === 'persist') {
264+
return () => {};
265+
}
266+
if (prop === 'isDefaultPrevented') {
267+
return () => defaultPrevented;
268+
}
269+
if (prop === 'isPropagationStopped') {
270+
return () => propagationStopped;
271+
}
272+
if (prop === 'preventDefault') {
273+
return () => {
274+
defaultPrevented = true;
275+
target.preventDefault();
276+
};
277+
}
278+
if (prop === 'stopPropagation') {
279+
return () => {
280+
propagationStopped = true;
281+
target.stopPropagation();
282+
};
283+
}
284+
if (prop === 'stopImmediatePropagation') {
285+
return () => {
286+
propagationStopped = true;
287+
if (typeof target.stopImmediatePropagation === 'function') {
288+
target.stopImmediatePropagation();
289+
}
290+
};
291+
}
292+
293+
const value = Reflect.get(target, prop);
294+
if (typeof value === 'function') {
295+
return value.bind(target);
296+
}
297+
298+
return value;
299+
},
300+
});
301+
};
302+
303+
/**
304+
* Temporarily wraps inline `on...` handlers on the clicked element so they receive
305+
* a proxied event with spoofed `isTrusted`.
306+
*
307+
* @param target Event target to inspect for inline handlers.
308+
* @param eventTypes Event types whose `on...` properties should be wrapped.
309+
*
310+
* @returns Cleanup function that restores original inline handlers.
311+
*/
312+
const wrapInlineHandlers = (target: EventTarget, eventTypes: Set<string>): (() => void) => {
313+
const originalHandlers = new Map<string, OnErrorEventHandler | EventListener | null>();
314+
const targetRecord = target as unknown as Record<string, unknown>;
315+
316+
eventTypes.forEach((eventType) => {
317+
const propertyName = `on${eventType}`;
318+
const handler = targetRecord[propertyName];
319+
320+
if (typeof handler !== 'function' || originalHandlers.has(propertyName)) {
321+
return;
322+
}
323+
324+
originalHandlers.set(propertyName, handler as EventListener);
325+
targetRecord[propertyName] = function wrappedInlineHandler(this: unknown, event: Event) {
326+
const onEventProxy = createEventProxy(event, eventType);
327+
return handler.call(this, onEventProxy);
328+
};
329+
});
330+
331+
return () => {
332+
originalHandlers.forEach((handler, propertyName) => {
333+
targetRecord[propertyName] = handler;
334+
});
335+
};
336+
};
337+
338+
/**
339+
* Creates a focus event for the direct React handler path.
340+
*
341+
* @returns Focus event object compatible with the current environment.
342+
*/
343+
const createFocusEvent = (): Event => {
344+
if (typeof FocusEvent === 'function') {
345+
return new FocusEvent('focus', {
346+
bubbles: false,
347+
cancelable: false,
348+
composed: true,
349+
relatedTarget: null,
350+
});
351+
}
352+
353+
return new Event('focus', {
354+
bubbles: false,
355+
cancelable: false,
356+
composed: true,
357+
});
358+
};
359+
360+
/**
361+
* Dispatches the synthetic pointer and mouse sequence used by the native
362+
* click path while temporarily wrapping inline handlers on the bubbling path.
363+
*/
364+
const dispatchNativeClick = (): void => {
365+
// Feature-detect PointerEvent for environments that don't support it
366+
const hasPointerEvent = typeof PointerEvent === 'function';
367+
const spoofedEventTypes = new Set([
368+
'click',
369+
'mousedown',
370+
'mouseup',
371+
'mouseover',
372+
'mouseenter',
373+
'pointerdown',
374+
'pointerup',
375+
'pointerover',
376+
'pointerenter',
377+
]);
378+
const restoreInlineHandlers = wrapInlineHandlers(element, spoofedEventTypes);
379+
380+
try {
381+
if (hasPointerEvent) {
382+
element.dispatchEvent(new PointerEvent('pointerover', commonOpts));
383+
element.dispatchEvent(new PointerEvent('pointerenter', noBubbleOpts));
384+
}
385+
element.dispatchEvent(new MouseEvent('mouseover', commonOpts));
386+
element.dispatchEvent(new MouseEvent('mouseenter', noBubbleOpts));
387+
if (hasPointerEvent) {
388+
element.dispatchEvent(new PointerEvent('pointerdown', commonOpts));
389+
}
390+
element.dispatchEvent(new MouseEvent('mousedown', commonOpts));
391+
element.focus();
392+
if (hasPointerEvent) {
393+
element.dispatchEvent(new PointerEvent('pointerup', releaseOpts));
394+
}
395+
element.dispatchEvent(new MouseEvent('mouseup', releaseOpts));
396+
element.dispatchEvent(new MouseEvent('click', releaseOpts));
397+
} finally {
398+
restoreInlineHandlers();
399+
}
400+
};
401+
402+
// Find React internal props key on the element
403+
const reactPropsKey = Object.keys(element).find((key) => key.startsWith(REACT_PROPS_KEY_PREFIX));
404+
405+
// If React props are found, try to use React's handlers
406+
// If clickType is 'native', skip React handlers and dispatch native click directly
407+
// https://github.com/AdguardTeam/Scriptlets/issues/554
408+
if (reactPropsKey && clickType !== NATIVE_CLICK_TYPE) {
409+
const reactProps = (element as unknown as Record<string, unknown>)[reactPropsKey] as {
410+
onFocus?: (event?: Event) => void;
411+
onClick?: (event?: Event) => void;
412+
} | undefined;
413+
414+
if (reactProps && typeof reactProps.onClick === 'function') {
415+
// Call onFocus first if available, as some React components require it
416+
if (typeof reactProps.onFocus === 'function') {
417+
const focusEvent = createFocusEvent();
418+
const eventFocusProxy = createEventProxy(focusEvent, 'focus', true);
419+
reactProps.onFocus.call(element, eventFocusProxy);
420+
}
421+
const clickEvent = new MouseEvent('click', releaseOpts);
422+
const eventClickProxy = createEventProxy(clickEvent, 'click', true);
423+
reactProps.onClick.call(element, eventClickProxy);
424+
return;
425+
}
253426
}
254-
element.dispatchEvent(new MouseEvent('mouseup', releaseOpts));
255-
element.dispatchEvent(new MouseEvent('click', releaseOpts));
427+
428+
dispatchNativeClick();
256429
};

src/scriptlets/trusted-click-element.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { type Source } from './scriptlets';
2727
* If `containsText` is specified, then it searches for all given selectors and clicks
2828
* the first element containing the specified text.
2929
* Deactivates after all elements have been clicked or by timeout (configurable).
30+
* Click behavior can be forced to native dispatch through `extraMatch`.
3031
*
3132
* ### Syntax
3233
*
@@ -45,6 +46,7 @@ import { type Source } from './scriptlets';
4546
* - `cookie` — test string or regex against cookies on a page
4647
* - `localStorage` — check if localStorage item is present
4748
* - `containsText` — check if clicked element contains specified text
49+
* - `clickType` — set click behavior; supported value is `native`
4850
* - `delay` — optional, time in **ms** to delay scriptlet execution, defaults to instant execution.
4951
* Must be a number less than `observerTimeout` (default 10 _seconds_)
5052
* which can be configured.
@@ -124,6 +126,12 @@ import { type Source } from './scriptlets';
124126
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', '!cookie:consent, !localStorage:promo')
125127
* ```
126128
*
129+
* 1. Force native click dispatch even if React handlers are detected
130+
*
131+
* ```adblock
132+
* example.com#%#//scriptlet('trusted-click-element', 'button[name="agree"]', 'clickType:native')
133+
* ```
134+
*
127135
* 1. Click element inside open shadow DOM, which could be selected by `div > button`, but is inside shadow host element with host element selected by `article .container`
128136
*
129137
* ```adblock
@@ -193,12 +201,14 @@ export function trustedClickElement(
193201
const COOKIE_MATCH_MARKER = 'cookie:';
194202
const LOCAL_STORAGE_MATCH_MARKER = 'localStorage:';
195203
const TEXT_MATCH_MARKER = 'containsText:';
204+
const CLICK_TYPE_MATCH_MARKER = 'clickType:';
205+
const CLICK_TYPE_NATIVE = 'native';
196206
const RELOAD_ON_FINAL_CLICK_MARKER = 'reloadAfterClick';
197207
const SELECTORS_DELIMITER = ',';
198208
const COOKIE_STRING_DELIMITER = ';';
199209
const COLON = ':';
200210
// Regex to split match pairs by commas, avoiding the ones included in regexes
201-
const EXTRA_MATCH_DELIMITER = /(,\s*){1}(?=!?cookie:|!?localStorage:|containsText:)/;
211+
const EXTRA_MATCH_DELIMITER = /(,\s*){1}(?=!?cookie:|!?localStorage:|containsText:|clickType:)/;
202212

203213
const sleep = (delayMs: number) => {
204214
return new Promise((resolve) => { setTimeout(resolve, delayMs); });
@@ -301,6 +311,7 @@ export function trustedClickElement(
301311
const cookieMatches: string[] = [];
302312
const localStorageMatches: string[] = [];
303313
let textMatches = '';
314+
let clickType = '';
304315
let isInvertedMatchCookie = false;
305316
let isInvertedMatchLocalStorage = false;
306317

@@ -329,6 +340,21 @@ export function trustedClickElement(
329340
const textMatch = matchValue.replace(TEXT_MATCH_MARKER, '');
330341
textMatches = textMatch;
331342
}
343+
if (matchStr.includes(CLICK_TYPE_MATCH_MARKER)) {
344+
const { isInvertedMatch, matchValue } = parseMatchArg(matchStr);
345+
if (isInvertedMatch) {
346+
logMessage(source, `Passed click type '${matchStr}' is invalid`);
347+
return;
348+
}
349+
350+
const passedClickType = matchValue.replace(CLICK_TYPE_MATCH_MARKER, '');
351+
if (passedClickType !== CLICK_TYPE_NATIVE) {
352+
logMessage(source, `Passed click type '${passedClickType}' is invalid`);
353+
return;
354+
}
355+
356+
clickType = passedClickType;
357+
}
332358
});
333359
}
334360

@@ -431,7 +457,7 @@ export function trustedClickElement(
431457
logMessage(source, `Could not find element: '${elementObj.selectorText}'`);
432458
return;
433459
}
434-
clickElement(element);
460+
clickElement(element, clickType);
435461
elementObj.clicked = true;
436462
} catch (error) {
437463
logMessage(source, `Could not click element: '${elementObj.selectorText}'`);
@@ -506,7 +532,7 @@ export function trustedClickElement(
506532
// if not, try to find the element again
507533
// https://github.com/AdguardTeam/Scriptlets/issues/391
508534
if (elementObj.element.isConnected) {
509-
clickElement(elementObj.element);
535+
clickElement(elementObj.element, clickType);
510536
elementObj.clicked = true;
511537
} else {
512538
findAndClickElement(elementObj);

0 commit comments

Comments
 (0)