|
import { createDispatcher } from './createDispatcher'; |
|
import { createEntries } from './createEntries'; |
|
import { createResizeObserver } from './createResizeObserver'; |
|
import { createTaskQueue } from './createTaskQueue'; |
|
import { onDocumentHidden } from './onDocumentHidden'; |
|
import { isEligible } from './isEligible'; |
|
|
|
|
|
/** |
|
* The required props expected to be defined by the consuming app (see `getProps` below). |
|
*/ |
|
const required_props = ['content_id', 'content_type']; |
|
|
|
/** |
|
* This module sets up a ResizeObserver for matched elements and will report the latest dimensions |
|
* captured for each of them to an analytics endpoint. |
|
* @param {Boolean} options.debug |
|
* @param {Array} options.content |
|
* @param {Function} options.content[].getProps - Invoked on each node with the current node as a |
|
* single argument (`getProps(node)`). The function must return an object containing `content_id` |
|
* and `content_type`. Any additional props will also be included in the request to the analytics |
|
* endpoint. |
|
* @param {String} options.content[].selector - A valid CSS selector to query DOM elements. |
|
* @param {String} options.content[].endpoint - The analytics endpoint to send the data to. |
|
* @param {Number} options.sample_rate - Rate at which to sample running this module (between 0 |
|
* and 1). |
|
* @return {Object} - An object exporting the `flush` method to send metrics and reset |
|
*/ |
|
const contentLayoutStability = (options = {}) => { |
|
|
|
const { |
|
debug = false, |
|
content = [], |
|
endpoint = '', |
|
sample_rate = 0.05, |
|
} = options; |
|
|
|
/** |
|
* If the script does not meet the criteria to execute, return a no-op function. |
|
*/ |
|
if (!isEligible({ sample_rate })) { |
|
return { flush: function() {}, ineligible: true }; |
|
} |
|
|
|
const { addEntry, addEntryContext, clearEntries, entries } = createEntries(); |
|
|
|
/** |
|
* Execute tasks when the browser is idle. |
|
*/ |
|
const { enqueueTask } = createTaskQueue(); |
|
|
|
const { observe, unobserve, disconnect } = createResizeObserver({ callback: addEntry }); |
|
|
|
const { send } = createDispatcher({ endpoint }); |
|
|
|
/** |
|
* Click handler for removing resize observer from the clicked element so no further resize |
|
* events are recorded once it is interacted with. It's possible that the height of an embed can |
|
* change based on a user interaction. For example, think of a quiz that appends new questions |
|
* or a result card as you answer. |
|
* @param {HTMLElement} node |
|
*/ |
|
const addClickHandler = (node) => { |
|
node.addEventListener('click', |
|
() => { |
|
unobserve(node); |
|
}, |
|
// Using addEventListener options object since IE is not supported by this library. |
|
{ |
|
/** |
|
* Automatically remove event listener when invoked. This is the only way the event |
|
* listener is removed (removeEventListener is never called). All browsers supported by |
|
* this library will garbage collect this properly. IE, for example, would have a memory |
|
* leak if the DOM element is removed while still having a click handler attached to it. |
|
*/ |
|
once: true, |
|
/** |
|
* Invoke at the capture phase to ensure the resize observer is removed before any click |
|
* handlers invoked at the bubbling phase cause the element to resize. |
|
*/ |
|
capture: true, |
|
} |
|
); |
|
}; |
|
|
|
/** |
|
* Processes all matched DOM elements from the list of selectors in the `content` array. |
|
*/ |
|
const init = () => { |
|
content.forEach(({ selector, getProps }) => { |
|
const nodes = document.querySelectorAll(selector); |
|
|
|
nodes.forEach(node => { |
|
/** |
|
* Wrapping in a try/catch block because getProps is a callback defined by consuming |
|
* apps and there is no strategy in place to prevent exceptions. |
|
*/ |
|
let props = {}; |
|
try { |
|
props = getProps(node); |
|
} catch (error) { |
|
if (debug) { |
|
console.error(error); |
|
} |
|
} |
|
|
|
/** |
|
* Validate props. Include node only if all of the required props are returned from the |
|
* provided getProps function. |
|
*/ |
|
if (required_props.every(prop => Object.keys(props).includes(prop))) { |
|
enqueueTask(() => { |
|
addEntryContext(node, props); |
|
observe(node); |
|
addClickHandler(node); |
|
}); |
|
} else if (debug) { |
|
console.error( |
|
`CLS: Missing required properties (${required_props.join(', ')}):`, |
|
{ node, props } |
|
); |
|
} |
|
}); |
|
}); |
|
}; |
|
|
|
const getEventDataFromEntries = () => { |
|
const data = []; |
|
|
|
entries.forEach(entry => data.push(entry)); |
|
|
|
return data; |
|
}; |
|
|
|
const sendMetrics = () => { |
|
/** |
|
* Event data will be discarded if the document has not finished loading all sub-resources. |
|
* This is because we assume the reported dimensions are inaccurate as content may not have |
|
* completed loading. |
|
*/ |
|
if (document.readyState === 'complete') { |
|
const event_data = getEventDataFromEntries(); |
|
send(event_data); |
|
} |
|
|
|
/** |
|
* Clear entries after the data attempted to send. This is because we assume the page is now |
|
* hidden or manually flushed (if a client side route change is about to occur). |
|
*/ |
|
clearEntries(); |
|
}; |
|
|
|
const { removeListener } = onDocumentHidden({ |
|
callback: () => { |
|
if (entries.size) { |
|
/** |
|
* Metrics should not be queue'd when the document becomes hidden, it should send immediately |
|
* to make sure the data is sent before the document is unloaded. |
|
*/ |
|
sendMetrics(); |
|
|
|
/** |
|
* disconnect resize observer. The onDocumentHidden module will remove it's own event |
|
* listener once the callback is invoked. |
|
*/ |
|
disconnect(); |
|
} |
|
}, |
|
}); |
|
|
|
const flush = () => { |
|
if (entries.size) { |
|
enqueueTask(sendMetrics); |
|
|
|
/** |
|
* The following cleanup functions must be invoked immediately and not queued. Otherwise the |
|
* resize observer will update the entries with a width and height of 0 when the DOM elements |
|
* are removed between client side routing. The queued sendMetrics method can get invoked |
|
* after a client side route change and would report the 0 values. |
|
*/ |
|
removeListener(); |
|
disconnect(); |
|
} |
|
}; |
|
|
|
/** Initialize! */ |
|
enqueueTask(init); |
|
|
|
return { flush }; |
|
}; |
|
|
|
|
|
export { contentLayoutStability }; |