Skip to content

Instantly share code, notes, and snippets.

@sanchezedgar
Last active May 31, 2022 19:01
Collect dimensions of DOM elements with unpredictable width and heights (e.g., iframes and social embeds like TikTok, Reddit, Twitter, etc)
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 };

Event Request (cls_event)

The collected data will be sent by making a POST request to an analytics endpoint. The request body will be JSON containing an array of objects. Example request body:

[
  {
    "content_id": "123456",
    "content_type": "tiktok",
    "height": 1037,
    "viewport_size": {
      "width": 1000,
      "height": 980
    },
    "width": 974
  },
]

Usage

contentLayoutStability(options)

This method returns an object containing a flush method for sending all the data collected and cleaning up event listeners.

Parameters Type Required Description
options.debug Boolean No
options.content Array Yes Each item consisting of an object containing a CSS selector and a corresponding getNodeData function for optionally retrieving object_id and object_type from DOM attributes.
options.content[].getProps Function Yes 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.
options.content[].selector String Yes A valid CSS selector to query DOM elements.
options.sample_rate Number No Rate at which to sample event requests (between 0 and 1).

Basic Setup

import { contentLayoutStability } from 'contentLayoutStability';

const options = {
  debug: true,
  content: [
    {
      // Would select all tweet embeds
      selector: '.twitter__container',
      getProps: (node) => {
        return {
          content_id: node.closest('[data-cls-content-id]').dataset.clsContentId,
          content_type: 'embed',
          // any additional properties will be passed along
        };
      },
    },
  ],
  endpoint: 'https://httpbin.org/post',
};

const cls = contentLayoutStability(options);

/* ... */

// To manually submit data (like on a client-side route change), 
// the `flush` method will send the data and clear any event listeners.
cls.flush();
/**
* Generates a `send` method for submitting analytics data if there is data available.
* @return {Function} obj.send
*/
const createDispatcher = ({ endpoint } = {}) => {
const send = async(data = []) => {
if (data.length) {
navigator.sendBeacon(endpoint, JSON.stringify(data));
}
};
return { send };
};
export { createDispatcher };
/**
* A manager for adding/removing entries (from the resize observer) containing the dimensions of
* observed DOM elements. Each entry is also enriched with data needed when submitting the data.
* @return {Object} - Containing methods for adding (`addEntry`) and clearing (`clearEntries`)
* entries as well as the entries themselves (`entries`).
*/
const createEntries = () => {
const entries = new Map();
/**
* Will store each observed node reference as a key, with the value being an object of associated
* props to include in each analytic request. Retrieving values from DOM attributes only need to
* happen once and can be saved here.
*/
const entry_context = new Map();
/**
* @param {HTMLElement} id - The DOM node reference the entry pertains to.
* @param {Object} data - Additional data to store for an entry. This would include, at a
* minimum, `width` and `height`.\
*/
const addEntry = (id, data) => {
entries.set(id, {
...entry_context.get(id) || {},
...data,
user_agent: navigator.userAgent,
viewport_size: {
width: Number(window.screen.width),
height: Number(window.screen.height),
},
});
};
const addEntryContext = (id, context) => {
entry_context.set(id, context);
};
const clearEntries = () => {
entries.clear();
entry_context.clear();
};
return {
addEntry,
addEntryContext,
clearEntries,
entries,
};
};
export { createEntries };
const createResizeObserver = ({ callback }) => {
const resizeObserver = new ResizeObserver(entries => {
if (typeof callback === 'function') {
for (let entry of entries) {
const dimentions = {};
/**
* `contentRect` may one day be deprecated in favor of `contentBoxSize` (and
* `borderBoxSize`). Recent versions of browsers have begun supporting these properties
* with some slight variations in how the spec was interpreted.
*/
if(entry.contentBoxSize) {
// Firefox implements `contentBoxSize` as a single content rect, rather than an array
const contentBoxSize = Array.isArray(entry.contentBoxSize)
? entry.contentBoxSize[0]
: entry.contentBoxSize;
dimentions.width = contentBoxSize.inlineSize; // width
dimentions.height = contentBoxSize.blockSize; // height
} else {
dimentions.width = entry.contentRect.width; // width
dimentions.height = entry.contentRect.height; // height
}
// Prevent invoking callback if width or height are `0`
if (dimentions.width && dimentions.height) {
callback(entry.target, dimentions);
}
}
}
});
const observe = (node) => {
resizeObserver.observe(node);
};
const unobserve = (node) => {
resizeObserver.unobserve(node);
};
const disconnect = () => {
resizeObserver.disconnect();
};
return {
disconnect,
observe,
unobserve,
};
};
export { createResizeObserver };
/**
* Creates a queue of tasks to be run by requestIdleCallback. Mostly based on the example provided
* by MDN here:
* https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API
* @param {Number} options.timeout - if the specified timeout is reached before the callback is
* executed within an idle period, a task is queued to execute it.
* @return {Object}
*/
const createTaskQueue = (options = {}) => {
const {
timeout = 2500,
} = options;
/**
* taskList is an Array of objects, each representing one task waiting to be run.
*/
const taskList = [];
/**
* taskHandle is an ID referencing the requestIdleCallback task currently being processed (can be
* used with window.cancelIdleCallback()).
*/
let taskHandle = null;
/**
* Shim for RequestIdelCallback/CancelIdleCallback based on the following GIST:
* https://gist.github.com/paullewis/55efe5d6f05434a96c36
* Unlike other API's used in this library, RequestIdleCallback will be shimmed/polyfilled in
* order to include Safari.
*/
const _requestIdleCallback = window.requestIdleCallback || (
(cb) => setTimeout(() => {
const start = Date.now();
cb({
didTimeout: false,
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
});
}, 1)
);
// eslint-disable-next-line no-unused-vars
const _cancelIdleCallback = window.cancelIdleCallback || ((id) => clearTimeout(id));
/**
* Runs the enqueued tasks. This idle callback handler will get called when the browser
* determines there's enough idle time available or the timeout expires.
* @param {IdleDeadline} deadline - an object describing the amount of time available and
* whether or not the callback has been run because the timeout period expired
*/
const runTaskQueue = (deadline) => {
/**
* loop will continue as long as
* - there's time left
* - the timeout limit was reached
* - as long as there are tasks in the task list
*/
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
let task = taskList.shift();
task.handler(task.data);
}
/**
* if there are still tasks left in the list after the while loop, call requestIdleCallback()
* again so that tasks can continue to process the next time there's idle time available.
*/
if (taskList.length) {
taskHandle = _requestIdleCallback(runTaskQueue, { timeout });
} else {
taskHandle = 0;
}
};
/**
* enqueues tasks for future execution.
* @param {Function} taskHandler - a function which will be called to handle the task.
* @param {Object} taskData - An object passed into the task handler as an input parameter, to
* allow the task to receive custom data.
*/
const enqueueTask = (taskHandler, taskData) => {
taskList.push({
handler: taskHandler,
data: taskData
});
/**
* If there isn't an idle callback yet, call requestIdleCallback() to create one.
*/
if (!taskHandle) {
taskHandle = _requestIdleCallback(runTaskQueue, { timeout });
}
};
return {
enqueueTask,
};
};
export { createTaskQueue };
/**
* Checks if the client meets all of the following requirements to help ensure the data is coming
* from modern browsers and presumably a higher performing device:
* - Supports Map, ResizeObserver, and sendBeacon.
* - When supported, respect the clients choice for "saveData". If true, do not make any network
* calls.
* - When supported, checks the effective connection type is categorized as having a 4g or higher
* speed. This increases the confidence that the dimensions received represent fully loaded
* embeds.
* @param {Number} options.sample_rate - Rate at which to sample running this module (between 0
* and 1).
* @return {Boolean}
*/
const isEligible = (options = {}) => {
const {
sample_rate = 1,
} = options;
let eligible = false;
/**
* All clients are required to support the following APIs. This should include the last few
* versions of Edge, Firefox, Chrome, Safari, and Opera.
*/
if (
Math.random() <= sample_rate
&& typeof window !== 'undefined'
&& typeof navigator !== 'undefined'
&& 'Map' in window
&& 'ResizeObserver' in window
&& 'sendBeacon' in navigator
) {
eligible = true;
}
/**
* The following criteria should only apply if the client supports them. This includes Edge,
* Chrome and Opera.
*/
if (
'connection' in navigator
&& 'effectiveType' in navigator.connection
&& 'saveData' in navigator.connection
// Not eligible if effective type is not 4g OR save data is true
&& (navigator.connection.effectiveType !== '4g' || navigator.connection.saveData)
) {
eligible = false;
}
return eligible;
};
export { isEligible };
/**
* Module to handle adding and removing of visibilitychange listeners for detecting when the
* documents visibility state has become "hidden".
* @param {Function} options.callback - The function to call when the document becomes hidden.
* @return {Function} obj.removeListener - Removes the `visibilitychange` and `pagehide` listeners
* added to the document.
*/
const onDocumentHidden = ({ callback }) => {
const listener = (event) => {
if (event.type === 'visibilitychange') {
if (document.visibilityState === 'hidden') {
callback();
document.removeEventListener('visibilitychange', listener);
document.removeEventListener('pagehide', listener);
}
} else { // The pagehide event (Safari support)
callback();
document.removeEventListener('visibilitychange', listener);
document.removeEventListener('pagehide', listener);
}
};
const removeListener = () => {
if (typeof callback === 'function') {
document.removeEventListener('visibilitychange', listener);
document.removeEventListener('pagehide', listener);
}
};
if (typeof callback === 'function') {
document.addEventListener('visibilitychange', listener);
/**
* Safari doesn't fire the visibilitychange event when navigating away from a document, so also
* add a listener for the `pagehide` event.
*/
document.addEventListener('pagehide', listener);
}
return { removeListener };
};
export { onDocumentHidden };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment