Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions packages/clarity-js/src/layout/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { shortid } from "@src/data/metadata";
import * as internal from "@src/diagnostic/internal";
import * as region from "@src/layout/region";
import * as selector from "@src/layout/selector";
import * as layouthash from "@src/layout/layouthash";

let index: number = 1;
let nodesMap: Map<Number, Node> = null; // Maps id => node to retrieve further node details using id.
let values: NodeValue[] = [];
Expand Down Expand Up @@ -86,8 +88,14 @@ export function getId(node: Node, autogen: boolean = false): number {
}

export function add(node: Node, parent: Node, data: NodeInfo, source: Source): void {
let id = getId(node, true);
let parentId = parent ? getId(parent) : null;

// Do not add detached nodes
if ((!parent || !parentId) && (node as ShadowRoot).host == null) {
return;
}

let id = getId(node, true);
let previousId = getPreviousId(node);
let parentValue: NodeValue = null;
let regionId = region.exists(node) ? id : null;
Expand Down Expand Up @@ -124,6 +132,7 @@ export function add(node: Node, parent: Node, data: NodeInfo, source: Source): v
updateSelector(values[id]);
updateImageSize(values[id]);
track(id, source);
layouthash.trackNode(values[id], node);
}

export function update(node: Node, parent: Node, data: NodeInfo, source: Source): void {
Expand Down Expand Up @@ -159,6 +168,7 @@ export function update(node: Node, parent: Node, data: NodeInfo, source: Source)
remove(id, source);
}


// Remove reference to this node from the old parent
if (oldParentId !== null && oldParentId >= 0) {
let nodeIndex = values[oldParentId].children.indexOf(id);
Expand Down Expand Up @@ -219,8 +229,8 @@ function privacy(node: Node, value: NodeValue, parent: NodeValue): void {
let meta: string = Constant.Empty;
const excludedPrivacyAttributes = [Constant.Class, Constant.Style]
Object.keys(attributes)
.filter((x) => !excludedPrivacyAttributes.includes(x as Constant))
.forEach((x) => (meta += attributes[x].toLowerCase()));
.filter((x) => !excludedPrivacyAttributes.includes(x as Constant))
.forEach((x) => (meta += attributes[x].toLowerCase()));
let exclude = maskExclude.some((x) => meta.indexOf(x) >= 0);
// Regardless of privacy mode, always mask off user input from input boxes or drop downs with two exceptions:
// (1) The node is detected to be one of the excluded fields, in which case we drop everything
Expand Down Expand Up @@ -254,10 +264,10 @@ function privacy(node: Node, value: NodeValue, parent: NodeValue): void {
break;
case tag === Constant.ImageTag:
// Mask images with blob src as it is not publicly available anyway.
if(attributes.src?.startsWith('blob:')){
if (attributes.src?.startsWith('blob:')) {
metadata.privacy = Privacy.TextImage;
}
break;
break;
}
}

Expand Down Expand Up @@ -356,12 +366,13 @@ function remove(id: number, source: Source): void {
function removeNodeFromNodesMap(id: number) {
// Shadow dom roots shouldn't be deleted,
// we should keep listening to the mutations there even they're not rendered in the DOM.
if(nodesMap.get(id).nodeType === Node.DOCUMENT_FRAGMENT_NODE){
if (nodesMap.get(id).nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return;
}
nodesMap.delete(id);

let value = id in values ? values[id] : null;
layouthash.untrackNode(value);
if (value && value.children) {
for (let childId of value.children) {
removeNodeFromNodesMap(childId);
Expand All @@ -371,17 +382,17 @@ function removeNodeFromNodesMap(id: number) {

function updateImageSize(value: NodeValue): void {
// If this element is a image node, and is masked, then track box model for the current element
if (value.data.tag === Constant.ImageTag && value.metadata.privacy === Privacy.TextImage) {
if (value.data.tag === Constant.ImageTag && value.metadata.privacy === Privacy.TextImage) {
let img = getNode(value.id) as HTMLImageElement;
// We will not capture the natural image dimensions until it loads.
if(img && (!img.complete || img.naturalWidth === 0)){
if (img && (!img.complete || img.naturalWidth === 0)) {
// This will trigger mutation to update the original width and height after image loads.
bind(img, 'load', () => {
img.setAttribute('data-clarity-loaded', `${shortid()}`);
})
}
value.metadata.size = [];
}
}
}

function getPreviousId(node: Node): number {
Expand Down
10 changes: 10 additions & 0 deletions packages/clarity-js/src/layout/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as dom from "@src/layout/dom";
import * as region from "@src/layout/region";
import * as style from "@src/layout/style";
import * as animation from "@src/layout/animation";
import * as layoutHash from "@src/layout/layouthash";

export default async function (type: Event, timer: Timer = null, ts: number = null): Promise<void> {
let eventTime = ts || time()
Expand Down Expand Up @@ -116,6 +117,15 @@ export default async function (type: Event, timer: Timer = null, ts: number = nu
}
if (type === Event.Mutation) { baseline.activity(eventTime); }
queue(tokenize(tokens), !config.lean);

// Queue layout hashes event with every mutation event
let layoutTokens: Token[] = [eventTime, Event.LayoutHash];
const hashes = layoutHash.getCurrentHashes();
layoutTokens.push(hashes.styleHash);
layoutTokens.push(hashes.layoutHash);
layoutTokens.push(hashes.alphaHash);
layoutTokens.push(hashes.betaHash);
queue(layoutTokens);
}
break;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/clarity-js/src/layout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FunctionNames } from "@clarity-types/performance";
import * as discover from "@src/layout/discover";
import * as doc from "@src/layout/document";
import * as dom from "@src/layout/dom";
import * as layoutHash from "@src/layout/layouthash";
import * as mutation from "@src/layout/mutation";
import * as region from "@src/layout/region";
import * as style from "@src/layout/style";
Expand All @@ -17,6 +18,7 @@ export function start(): void {
doc.start();
region.start();
dom.start();
layoutHash.start();
if (config.delayDom) {
// Lazy load layout module as part of page load time performance improvements experiment
bind(window, 'load', () => {
Expand All @@ -38,4 +40,5 @@ export function stop(): void {
doc.stop();
style.stop();
animation.stop();
layoutHash.stop();
}
189 changes: 189 additions & 0 deletions packages/clarity-js/src/layout/layouthash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { LayoutHash, NodeValue } from "@clarity-types/layout";
import * as dom from "@src/layout/dom";

let allowedAttributes = ['class', 'style', 'visibile', 'hidden', 'width', 'height'];
let hashedAttributes = ['class', 'style'];
let blockedNodeTypes = ['SCRIPT', 'IFRAME', 'HEAD', 'META'];

let styleHashesMap = new Map();
let layoutHashesMap = new Map();
let alphaHashesMap = new Map();
let betaHashesMap = new Map();
let hiddenNodeList = new Set();

export function start(): void {
reset();
}

export function stop(): void {
reset();
}

export function trackMutation(mutation: MutationRecord): void {
// Only listen to attribute mutations
if (mutation.type === "attributes") {

// Get node details
const id = dom.getId(mutation.target, false);
let nodeValue = dom.getValue(id);
if (!nodeValue || !nodeValue.hash || !nodeValue.hash[0] || !nodeValue.hash[1]) {
return;
}

// Verify that attribute value changed, as sometimes mutations are triggered with no attribute changes due to modifiers
let oldAttributeValue = mutation.oldValue;
let newAttributeValue = (mutation.target as Element).getAttribute(mutation.attributeName);
if (oldAttributeValue == newAttributeValue) {
return;
}

// If value of any attribute that can affect visuals changes, recompute layout hashes for element and subtree
if (allowedAttributes.includes(mutation.attributeName)) {
layoutHash(mutation.target, id);
recomputeSubtreeLayoutHash(id);

// If change is in attributes we should hash for style, then compute and apply delta hash
if (hashedAttributes.includes(mutation.attributeName)) {
let hashedAttributeDelta = cyrb53(newAttributeValue) ^ cyrb53(oldAttributeValue);
styleHashesMap.set(id, styleHashesMap.get(id) ^ hashedAttributeDelta);

// Retrack alpha & beta hashes as changes to classes may cause them to recompute
alphaHashesMap.set(id, cyrb53(nodeValue.hash[0]));
betaHashesMap.set(id, cyrb53(nodeValue.hash[1]));
}
}

/// TODO: Check if mutations happen to inline css style elements.
}
}

export function trackNode(nodeMetadata: NodeValue, node: Node): void {
// Filter out nodes that dont have necessary metadata, or are pseudo-elements
if (!nodeMetadata ||
!nodeMetadata.hash ||
!nodeMetadata.hash[0] ||
!nodeMetadata.hash[1] ||
!nodeMetadata.data ||
!nodeMetadata.data.tag ||
blockedNodeTypes.includes(nodeMetadata.data.tag) ||
nodeMetadata.data.tag.startsWith('*') ||
nodeMetadata.data.tag.includes(':')) {
return;
}

// Track node hashes alpha & beta
let id = nodeMetadata.id;
alphaHashesMap.set(id, cyrb53(nodeMetadata.hash[0]));
betaHashesMap.set(id, cyrb53(nodeMetadata.hash[1]));

// Compute node layout hash. Not required to compute for subtree, as this function will be called per node
layoutHash(node, id);

// Compute style hash for node
let styleHash = 0
for (let attributeName of hashedAttributes) {
let attributeValue = null;
if (nodeMetadata.data.attributes && nodeMetadata.data.attributes[attributeName]) {
attributeValue = nodeMetadata.data.attributes[attributeName];
}
styleHash ^= cyrb53(attributeValue);
}
styleHashesMap.set(id, styleHash);
}

export function untrackNode(nodeMetadata: NodeValue): void {
if (!nodeMetadata || !nodeMetadata.id) {
return;
}

// Remove all references to the node
const id = nodeMetadata.id;
hiddenNodeList.delete(id)
layoutHashesMap.delete(id);
styleHashesMap.delete(id);
alphaHashesMap.delete(id);
betaHashesMap.delete(id);
}

export function getCurrentHashes(): LayoutHash {
let layoutHash: LayoutHash = {
styleHash: combineHash(styleHashesMap),
layoutHash: combineHash(layoutHashesMap),
alphaHash: combineHash(alphaHashesMap),
betaHash: combineHash(betaHashesMap)
};

return layoutHash;
}

function combineHash(hashMap: Map<number, number>): number {
let combinedHash = 0;
for (var el of hashMap) {
if (hiddenNodeList.has(el[0])) {
continue;
}
combinedHash ^= el[1];
}

return combinedHash;
}

function recomputeSubtreeLayoutHash(id: number): void {
let nodeValue = dom.getValue(id);
for (let child of nodeValue.children) {
// This method is intended for recomputing already captured elements
// It should not capture new elements
if (!layoutHashesMap.has(child)) {
continue;
}

let childNode = dom.getNode(child);
layoutHash(childNode, child);
recomputeSubtreeLayoutHash(child);
}
}

function layoutHash(node: Node, id: number): void {
let currentWidth = (node as HTMLElement).offsetWidth;
let currentHeight = (node as HTMLElement).offsetHeight;
let isHidden = !currentHeight || !currentWidth || currentWidth == 0 || currentHeight == 0;

if (isHidden) {
hiddenNodeList.add(id);
layoutHashesMap.set(id, 0);
} else {
let layoutHash = cyrb53(currentWidth.toString()) ^ cyrb53(currentHeight.toString());
layoutHashesMap.set(id, layoutHash);

hiddenNodeList.delete(id);
}
}

function reset(): void {
styleHashesMap = new Map();
layoutHashesMap = new Map();
alphaHashesMap = new Map();
betaHashesMap = new Map();
hiddenNodeList = new Set();
}

// Public domain 53 bit hash function
// https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js
const cyrb53 = (str: string, seed = 0) => {
if (str == null) {
return 0;
}

let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);

return 4294967296 * (2097151 & h2) + (h1 >>> 0);
};
Loading