@bschauer/webtools-mcp-server
Version:
MCP server providing web analysis tools including screenshot, debug, performance, security, accessibility, SEO, and asset optimization capabilities
287 lines (244 loc) • 10.5 kB
JavaScript
/**
* Layout thrashing analysis module
*/
/**
* Analyze layout thrashing in the trace data
* @param {Array} events - All trace events
* @returns {Object|null} Layout thrashing analysis or null if no layout thrashing found
*/
export function analyzeLayoutThrashing(events) {
// Find layout and style events
const layoutEvents = events.filter((event) => event.name === "Layout" || event.name === "UpdateLayoutTree");
const recalcStyleEvents = events.filter((event) => event.name === "RecalculateStyles");
if (layoutEvents.length === 0) {
return null;
}
// Find layout invalidation sequences (read-write-read patterns)
const layoutSequences = findLayoutThrashingSequences(events);
// Find forced layout events (layout operations triggered by JavaScript)
const forcedLayouts = findForcedLayouts(events);
// Create a DOM mutation heatmap
const domMutations = events.filter((event) => event.name === "UpdateLayoutTree" || event.name === "Layout" || event.name === "RecalculateStyles");
const mutationHeatmap = createDomMutationHeatmap(domMutations);
// Identify the worst offenders
const worstOffenders = findWorstLayoutThrashingOffenders(events);
// Generate recommendations
const recommendations = generateLayoutThrashingRecommendations(layoutEvents, recalcStyleEvents, layoutSequences, forcedLayouts, worstOffenders);
return {
type: "layout_thrashing",
description: `Detected ${layoutSequences.length} layout thrashing sequences and ${forcedLayouts.length} forced layouts`,
details: {
layoutOperations: layoutEvents.length,
styleRecalculations: recalcStyleEvents.length,
layoutThrashingSequences: layoutSequences,
forcedLayouts: forcedLayouts,
domMutationHeatmap: mutationHeatmap,
worstOffenders: worstOffenders,
recommendations: recommendations,
},
};
}
/**
* Find sequences of events that indicate layout thrashing
* @param {Array} events - All trace events
* @returns {Array} Layout thrashing sequences
*/
function findLayoutThrashingSequences(events) {
const sequences = [];
const layoutReadEvents = events.filter((e) => e.name === "Layout" && e.args?.data?.beginData?.stackTrace);
// For each layout read, check if it's followed by a write and then another read
for (let i = 0; i < layoutReadEvents.length - 1; i++) {
const currentRead = layoutReadEvents[i];
const nextRead = layoutReadEvents[i + 1];
// Find any DOM write events between these two reads
const writesBetween = events.filter((e) => e.ts > currentRead.ts && e.ts < nextRead.ts && (e.name === "UpdateLayoutTree" || e.name.includes("Mutation")));
if (writesBetween.length > 0) {
sequences.push({
firstRead: {
time: currentRead.ts,
duration: currentRead.dur / 1000,
stackTrace: currentRead.args?.data?.beginData?.stackTrace || [],
},
writes: writesBetween.map((w) => ({
time: w.ts,
type: w.name,
duration: w.dur / 1000,
})),
secondRead: {
time: nextRead.ts,
duration: nextRead.dur / 1000,
stackTrace: nextRead.args?.data?.beginData?.stackTrace || [],
},
timeBetweenReads: (nextRead.ts - currentRead.ts) / 1000,
impact: nextRead.dur / 1000, // The duration of the forced layout
});
}
}
return sequences;
}
/**
* Find layout operations that were forced by JavaScript
* @param {Array} events - All trace events
* @returns {Array} Forced layout operations
*/
function findForcedLayouts(events) {
const forcedLayouts = [];
// Look for Layout events with stack traces (indicating they were forced by JS)
const layoutEvents = events.filter((e) => e.name === "Layout" && e.args?.data?.beginData?.stackTrace && e.args.data.beginData.stackTrace.length > 0);
for (const layout of layoutEvents) {
// Find the JS event that triggered this layout
const jsEvents = events.filter((e) => e.ts < layout.ts && e.ts + e.dur >= layout.ts && (e.name === "V8.Execute" || e.name === "FunctionCall" || e.name === "EvaluateScript"));
if (jsEvents.length > 0) {
const jsEvent = jsEvents[jsEvents.length - 1]; // Get the most recent JS event
forcedLayouts.push({
layout: {
time: layout.ts,
duration: layout.dur / 1000,
},
javascript: {
time: jsEvent.ts,
duration: jsEvent.dur / 1000,
functionName: jsEvent.args?.data?.functionName || "anonymous",
url: jsEvent.args?.data?.url || jsEvent.args?.data?.fileName || "unknown",
lineNumber: jsEvent.args?.data?.lineNumber,
columnNumber: jsEvent.args?.data?.columnNumber,
},
stackTrace: layout.args?.data?.beginData?.stackTrace || [],
});
}
}
return forcedLayouts;
}
/**
* Create a heatmap of DOM mutations
* @param {Array} domEvents - DOM-related events
* @returns {Object} DOM mutation heatmap
*/
function createDomMutationHeatmap(domEvents) {
// Group mutations by their target (if available)
const mutationsByTarget = {};
for (const event of domEvents) {
const target = event.args?.data?.nodeName || event.args?.data?.selector || event.args?.data?.tagName || "unknown";
if (!mutationsByTarget[target]) {
mutationsByTarget[target] = {
count: 0,
totalDuration: 0,
events: [],
};
}
mutationsByTarget[target].count++;
mutationsByTarget[target].totalDuration += event.dur || 0;
mutationsByTarget[target].events.push({
type: event.name,
time: event.ts,
duration: event.dur / 1000,
});
}
// Convert to array and sort by count
const heatmapEntries = Object.keys(mutationsByTarget).map((target) => ({
target,
count: mutationsByTarget[target].count,
totalDuration: mutationsByTarget[target].totalDuration / 1000,
averageDuration: mutationsByTarget[target].totalDuration / mutationsByTarget[target].count / 1000,
events: mutationsByTarget[target].events.slice(0, 10), // Limit to 10 events per target
}));
heatmapEntries.sort((a, b) => b.count - a.count);
return {
topMutationTargets: heatmapEntries.slice(0, 10), // Top 10 targets
totalMutations: domEvents.length,
totalMutationTime: domEvents.reduce((sum, e) => sum + (e.dur || 0), 0) / 1000,
};
}
/**
* Find the worst offenders for layout thrashing
* @param {Array} events - All trace events
* @returns {Array} Worst layout thrashing offenders
*/
function findWorstLayoutThrashingOffenders(events) {
// Look for JavaScript functions that trigger layout operations
const layoutEvents = events.filter((e) => e.name === "Layout" && e.args?.data?.beginData?.stackTrace);
// Group by the script URL and function name if available
const offenderMap = {};
for (const layout of layoutEvents) {
const stackTrace = layout.args?.data?.beginData?.stackTrace || [];
if (stackTrace.length > 0) {
// Use the top of the stack trace as the offender
const topFrame = stackTrace[0];
const key = `${topFrame.url}:${topFrame.functionName}:${topFrame.lineNumber}`;
if (!offenderMap[key]) {
offenderMap[key] = {
url: topFrame.url,
functionName: topFrame.functionName || "anonymous",
lineNumber: topFrame.lineNumber,
columnNumber: topFrame.columnNumber,
count: 0,
totalDuration: 0,
};
}
offenderMap[key].count++;
offenderMap[key].totalDuration += layout.dur || 0;
}
}
// Convert to array and sort by count
const offenders = Object.values(offenderMap);
offenders.sort((a, b) => b.count - a.count);
return offenders.slice(0, 10).map((o) => ({
...o,
totalDuration: o.totalDuration / 1000,
averageDuration: o.totalDuration / o.count / 1000,
}));
}
/**
* Generate recommendations for layout thrashing
* @param {Array} layoutEvents - Layout events
* @param {Array} recalcStyleEvents - Style recalculation events
* @param {Array} layoutSequences - Layout thrashing sequences
* @param {Array} forcedLayouts - Forced layout operations
* @param {Array} worstOffenders - Worst layout thrashing offenders
* @returns {Array} Recommendations
*/
function generateLayoutThrashingRecommendations(layoutEvents, recalcStyleEvents, layoutSequences, forcedLayouts, worstOffenders) {
const recommendations = [];
// Check for layout thrashing sequences
if (layoutSequences.length > 0) {
recommendations.push({
type: "layout_thrashing_prevention",
description: `${layoutSequences.length} layout thrashing sequences detected`,
recommendation: "Batch DOM reads and writes to prevent layout thrashing. Read all properties first, then perform all DOM updates.",
});
// If we have specific offenders, add more detailed recommendations
if (worstOffenders.length > 0) {
const topOffender = worstOffenders[0];
recommendations.push({
type: "specific_layout_thrashing",
description: `Function ${topOffender.functionName} in ${topOffender.url} triggered ${topOffender.count} layout operations`,
recommendation: `Review this function at line ${topOffender.lineNumber} and batch DOM reads and writes.`,
});
}
}
// Check for forced layouts
if (forcedLayouts.length > 0) {
recommendations.push({
type: "forced_layout_prevention",
description: `${forcedLayouts.length} forced layout operations detected`,
recommendation: "Avoid accessing properties that trigger layout calculations (like offsetWidth, clientHeight, etc.) immediately after DOM modifications.",
});
}
// Check for excessive style recalculations
if (recalcStyleEvents.length > 50) {
recommendations.push({
type: "style_recalculation_optimization",
description: `${recalcStyleEvents.length} style recalculations detected`,
recommendation: "Minimize style changes, use CSS classes instead of inline styles, and consider using CSS containment.",
});
}
// Check for frequent layout operations
if (layoutEvents.length > 100) {
recommendations.push({
type: "layout_frequency_reduction",
description: `High number of layout operations (${layoutEvents.length})`,
recommendation: "Reduce the frequency of layout operations by batching DOM updates and using requestAnimationFrame for visual changes.",
});
}
return recommendations;
}