diff --git a/CHANGELOG.md b/CHANGELOG.md index 7842575d..73cccfd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,15 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - new values to `set-cookie` and `set-cookie-reload` scriptlets: `essential`, `nonessential` [#436] - `trusted-set-session-storage-item` scriptlet [#426] +### Fixed + +- `trusted-click-element` scriptlet does not click on an element that is already in the DOM [#437] + [Unreleased]: https://github.com/AdguardTeam/Scriptlets/compare/v1.11.6...HEAD [#435]: https://github.com/AdguardTeam/Scriptlets/issues/435 [#436]: https://github.com/AdguardTeam/Scriptlets/issues/436 [#426]: https://github.com/AdguardTeam/Scriptlets/issues/426 +[#437]: https://github.com/AdguardTeam/Scriptlets/issues/437 ## [v1.11.6] - 2024-07-08 diff --git a/src/scriptlets/trusted-click-element.ts b/src/scriptlets/trusted-click-element.ts index e739ff07..fc4c3430 100644 --- a/src/scriptlets/trusted-click-element.ts +++ b/src/scriptlets/trusted-click-element.ts @@ -347,14 +347,12 @@ export function trustedClickElement( }; /** - * Query all selectors from queue on each mutation - * Each selector is swapped to null in selectorsSequence on founding corresponding element - * - * We start looking for elements before possible delay is over, to avoid cases - * when delay is getting off after the last mutation took place. + * Processes a sequence of selectors, handling elements found in DOM (and shadow DOM), + * and updates the sequence. * + * @returns {string[]} The updated selectors sequence, with fulfilled selectors set to null. */ - const findElements = (mutations: MutationRecord[], observer: MutationObserver) => { + const fulfillAndHandleSelectors = () => { const fulfilledSelectors: string[] = []; selectorsSequence.forEach((selector, i) => { if (!selector) { @@ -376,6 +374,20 @@ export function trustedClickElement( : selector; }); + return selectorsSequence; + }; + + /** + * Queries all selectors from queue on each mutation + * + * We start looking for elements before possible delay is over, to avoid cases + * when delay is getting off after the last mutation took place. + * + */ + const findElements = (mutations: MutationRecord[], observer: MutationObserver) => { + // TODO: try to make the function cleaner — avoid usage of selectorsSequence from the outer scope + selectorsSequence = fulfillAndHandleSelectors(); + // Disconnect observer after finding all elements const allSelectorsFulfilled = selectorsSequence.every((selector) => selector === null); if (allSelectorsFulfilled) { @@ -383,13 +395,49 @@ export function trustedClickElement( } }; - const observer = new MutationObserver(throttle(findElements, THROTTLE_DELAY_MS)); - observer.observe(document.documentElement, { - attributes: true, - childList: true, - subtree: true, - }); + /** + * Initializes a `MutationObserver` to watch for changes in the DOM. + * The observer is set up to monitor changes in attributes, child nodes, and subtree. + * A timeout is set to disconnect the observer if no elements are found within the specified time. + */ + const initializeMutationObserver = () => { + const observer = new MutationObserver(throttle(findElements, THROTTLE_DELAY_MS)); + observer.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + + // Set timeout to disconnect observer if elements are not found within the specified time + setTimeout(() => observer.disconnect(), OBSERVER_TIMEOUT_MS); + }; + /** + * Checks if elements are already present in the DOM. + * If elements are found, they are clicked. + * If elements are not found, the observer is initialized. + */ + const checkInitialElements = () => { + const foundElements = selectorsSequence.every((selector) => { + if (!selector) { + return false; + } + const element = queryShadowSelector(selector); + return !!element; + }); + if (foundElements) { + // Click previously collected elements + fulfillAndHandleSelectors(); + } else { + // Initialize MutationObserver if elements were not found initially + initializeMutationObserver(); + } + }; + + // Run the initial check + checkInitialElements(); + + // If there's a delay before clicking elements, use a timeout if (parsedDelay) { setTimeout(() => { // Click previously collected elements @@ -397,8 +445,6 @@ export function trustedClickElement( canClick = true; }, parsedDelay); } - - setTimeout(() => observer.disconnect(), OBSERVER_TIMEOUT_MS); } trustedClickElement.names = [ diff --git a/tests/scriptlets/trusted-click-element.test.js b/tests/scriptlets/trusted-click-element.test.js index 0e0a8cb5..ef34baa3 100644 --- a/tests/scriptlets/trusted-click-element.test.js +++ b/tests/scriptlets/trusted-click-element.test.js @@ -57,17 +57,37 @@ const afterEach = () => { module(name, { beforeEach, afterEach }); -test('Single element clicked', (assert) => { +test('Element already in DOM is clicked', (assert) => { const ELEM_COUNT = 1; - // Check elements for being clicked and hit func execution const ASSERTIONS = ELEM_COUNT + 1; assert.expect(ASSERTIONS); const done = assert.async(); const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; + const panel = createPanel(); + const clickable = createClickable(1); + panel.appendChild(clickable); runScriptlet(name, [selectorsString]); + + setTimeout(() => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed'); + done(); + }, 150); +}); + +test('Element added to DOM is clicked', (assert) => { + const ELEM_COUNT = 1; + const ASSERTIONS = ELEM_COUNT + 1; + assert.expect(ASSERTIONS); + + const done = assert.async(); + const selectorsString = `#${PANEL_ID} > #${CLICKABLE_NAME}${ELEM_COUNT}`; const panel = createPanel(); + + runScriptlet(name, [selectorsString]); + const clickable = createClickable(1); panel.appendChild(clickable); @@ -78,6 +98,42 @@ test('Single element clicked', (assert) => { }, 150); }); +test('Multiple elements clicked - one element loaded before scriptlet, rest added later', (assert) => { + const CLICK_ORDER = [1, 2, 3]; + // Assert elements for being clicked, hit func execution & click order + const ASSERTIONS = CLICK_ORDER.length + 2; + assert.expect(ASSERTIONS); + const done = assert.async(); + + const selectorsString = createSelectorsString(CLICK_ORDER); + + const panel = createPanel(); + + const clickables = []; + const clickable1 = createClickable(1); + panel.appendChild(clickable1); + clickables.push(clickable1); + + runScriptlet(name, [selectorsString]); + + const clickable2 = createClickable(2); + panel.appendChild(clickable2); + clickables.push(clickable2); + + const clickable3 = createClickable(3); + panel.appendChild(clickable3); + clickables.push(clickable3); + + setTimeout(() => { + clickables.forEach((clickable) => { + assert.ok(clickable.getAttribute('clicked'), 'Element should be clicked'); + }); + assert.strictEqual(CLICK_ORDER.join(), window.clickOrder.join(), 'Elements were clicked in a given order'); + assert.strictEqual(window.hit, 'FIRED', 'hit func executed'); + done(); + }, 400); +}); + test('Single element clicked, delay is set', (assert) => { const ELEM_COUNT = 1; const DELAY = 300;