Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Visibility trigger support for querySelectorAll #26886

Merged
merged 20 commits into from
Apr 20, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
90 changes: 83 additions & 7 deletions extensions/amp-analytics/0.1/analytics-root.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
} from '../../../src/dom';
import {dev, user, userAssert} from '../../../src/log';
import {getMode} from '../../../src/mode';
import {isArray} from '../../../src/types';
import {isExperimentOn} from '../../../src/experiments';
import {layoutRectLtwh} from '../../../src/layout-rect';
import {map} from '../../../src/utils/object';
import {provideVisibilityManager} from './visibility-manager';
Expand Down Expand Up @@ -279,6 +281,39 @@ export class AnalyticsRoot {
});
}

/**
* @param {!Array<string>} selectors Array of DOM query selectors.
* @return {!Promise<!Array<!Element>>} Element corresponding to the selector.
*/
getElementsByQuerySelectorAll_(selectors) {
// Wait for document-ready to avoid false missed searches
return this.ampdoc.whenReady().then(() => {
let elements = [];
for (let i = 0; i < selectors.length; i++) {
let nodeList;
const elementArray = [];
const selector = selectors[i];
try {
nodeList = this.getRoot().querySelectorAll(selector);
} catch (e) {
userAssert(false, `Invalid query selector ${selector}`);
}
for (let j = 0; j < nodeList.length; j++) {
if (this.contains(nodeList[j])) {
elementArray.push(nodeList[j]);
}
}
userAssert(elementArray.length, `Element "${selector}" not found`);
this.verifyAmpElements_(elementArray, selector);
elements = elements.concat(elementArray);
}
// Return unique
return elements.filter(
(element, index) => elements.indexOf(element) === index
);
});
}

/**
* Searches the AMP element that matches the selector within the scope of the
* analytics root in relationship to the specified context node.
Expand All @@ -287,22 +322,63 @@ export class AnalyticsRoot {
* @param {string} selector DOM query selector.
* @param {?string=} selectionMethod Allowed values are `null`,
* `'closest'` and `'scope'`.
* @param {boolean=} opt_multiSelectorOn multi-selector expriment
* @return {!Promise<!AmpElement>} AMP element corresponding to the selector if found.
*/
getAmpElement(context, selector, selectionMethod, opt_multiSelectorOn) {
getAmpElement(context, selector, selectionMethod) {
return this.getElement(context, selector, selectionMethod).then(
(element) => {
userAssert(
element.classList.contains('i-amphtml-element'),
'Element "%s" is required to be an AMP element',
selector
);
this.verifyAmpElements_([element], selector);
return element;
}
);
}

/**
* Searches for the AMP element(s) that matches the selector
* within the scope of the analytics root in relationship to
* the specified context node.
*
* @param {!Element} context
* @param {!Array<string>|string} selectors DOM query selector(s).
* @param {?string=} selectionMethod Allowed values are `null`,
* `'closest'` and `'scope'`.
* @return {!Promise<!Array<!AmpElement>>} Array of AMP elements corresponding to the selector if found.
*/
getAmpElements(context, selectors, selectionMethod) {
if (
isExperimentOn(this.ampdoc.win, 'visibility-trigger-improvements') &&
isArray(selectors)
) {
userAssert(
!selectionMethod,
'Cannot have selectionMethod %s defined with an array selector.',
selectionMethod
);
return this.getElementsByQuerySelectorAll_(
/** @type {!Array<string>} */ (selectors)
);
}
return this.getAmpElement(
context,
/** @type {string} */ (selectors),
selectionMethod
).then((element) => [element]);
}

/**
* @param {!Array<Element>} elements
* @param {string} selector
*/
verifyAmpElements_(elements, selector) {
for (let i = 0; i < elements.length; i++) {
userAssert(
elements[i].classList.contains('i-amphtml-element'),
'Element "%s" is required to be an AMP element',
selector
);
}
}

/**
* Creates listener-filter for DOM events to check against the specified
* selector. If the node (or its ancestors) match the selector the listener
Expand Down
75 changes: 42 additions & 33 deletions extensions/amp-analytics/0.1/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ import {dev, devAssert, user, userAssert} from '../../../src/log';
import {dict, hasOwn} from '../../../src/utils/object';
import {getData} from '../../../src/event-helper';
import {getDataParamsFromAttributes} from '../../../src/dom';
import {isEnumValue, isFiniteNumber} from '../../../src/types';
import {isExperimentOn} from '../../../src/experiments';
import {isArray, isEnumValue, isFiniteNumber} from '../../../src/types';
import {startsWith} from '../../../src/string';

const SCROLL_PRECISION_PERCENT = 5;
Expand Down Expand Up @@ -1429,12 +1428,10 @@ export class VisibilityTracker extends EventTracker {
const visibilitySpec = config['visibilitySpec'] || {};
const selector = config['selector'] || visibilitySpec['selector'];
const waitForSpec = visibilitySpec['waitFor'];
let readyPromiseWaitForSpec;
let reportWhenSpec = visibilitySpec['reportWhen'];
let createReportReadyPromiseFunc = null;
const unlistenPromises = [];
const multiSelectorVisibilityOn =
isExperimentOn(this.root.ampdoc.win, 'visibility-trigger-improvements') &&
Array.isArray(selector);
if (reportWhenSpec) {
userAssert(
!visibilitySpec['repeat'],
Expand Down Expand Up @@ -1482,12 +1479,13 @@ export class VisibilityTracker extends EventTracker {
if (!selector || selector == ':root' || selector == ':host') {
// When `selector` is specified, we always use "ini-load" signal as
// a "ready" signal.
readyPromiseWaitForSpec = waitForSpec || (selector ? 'ini-load' : null);
unlistenPromises.push(
visibilityManagerPromise.then(
(visibilityManager) => {
return visibilityManager.listenRoot(
visibilitySpec,
this.getReadyPromise(waitForSpec, selector),
this.getReadyPromise(readyPromiseWaitForSpec),
createReportReadyPromiseFunc,
this.onEvent_.bind(
this,
Expand All @@ -1506,32 +1504,32 @@ export class VisibilityTracker extends EventTracker {
// Array selectors do not suppor the special cases: ':host' & ':root'
const selectionMethod =
config['selectionMethod'] || visibilitySpec['selectionMethod'];
const selectors = Array.isArray(selector) ? selector : [selector];
for (let i = 0; i < selectors.length; i++) {
unlistenPromises.push(
this.root
.getAmpElement(
context.parentElement || context,
selectors[i],
selectionMethod,
multiSelectorVisibilityOn
)
.then((element) =>
readyPromiseWaitForSpec = waitForSpec || 'ini-load';
this.assertUniqueSelectors_(selector);
this.root
.getAmpElements(
context.parentElement || context,
selector,
selectionMethod
)
.then((elements) => {
for (let i = 0; i < elements.length; i++) {
unlistenPromises.push(
visibilityManagerPromise.then(
(visibilityManager) => {
return visibilityManager.listenElement(
element,
elements[i],
visibilitySpec,
this.getReadyPromise(waitForSpec, selectors[i], element),
this.getReadyPromise(readyPromiseWaitForSpec, elements[i]),
createReportReadyPromiseFunc,
this.onEvent_.bind(this, eventType, listener, element)
this.onEvent_.bind(this, eventType, listener, elements[i])
);
},
() => {}
)
)
);
}
);
}
});
}

return function () {
Expand All @@ -1543,6 +1541,24 @@ export class VisibilityTracker extends EventTracker {
};
}

/**
* Assert that the selectors are all unique
* @param {!Array<string>|string} selectors
*/
assertUniqueSelectors_(selectors) {
if (isArray(selectors)) {
const map = {};
for (let i = 0; i < selectors.length; i++) {
userAssert(
!map[selectors[i]],
'Cannot have duplicate selectors in selectors list: %s',
selectors
);
map[selectors[i]] = selectors[i];
}
}
}

/**
* Assert that the setting is measurable with host API
* @param {string=} selector
Expand Down Expand Up @@ -1642,21 +1658,14 @@ export class VisibilityTracker extends EventTracker {

/**
* @param {string|undefined} waitForSpec
* @param {string|undefined} selector
* @param {Element=} opt_element
* @return {?Promise}
* @visibleForTesting
*/
getReadyPromise(waitForSpec, selector, opt_element) {
getReadyPromise(waitForSpec, opt_element) {
if (!waitForSpec) {
// Default case:
if (!selector) {
// waitFor selector is not defined, wait for nothing
return null;
} else {
// otherwise wait for ini-load by default
waitForSpec = 'ini-load';
}
// Default case, waitFor selector is not defined, wait for nothing
return null;
}

const trackerWhitelist = getTrackerTypesForParentType('visible');
Expand Down