Skip to content

Instantly share code, notes, and snippets.

@sanchezedgar
Last active May 31, 2022 19:01
Show Gist options
  • Save sanchezedgar/0c705d0eaa31360ec4380113df7350fd to your computer and use it in GitHub Desktop.
Save sanchezedgar/0c705d0eaa31360ec4380113df7350fd to your computer and use it in GitHub Desktop.
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