Repository: preactjs/preact-devtools
Pull request #373 (2022-05-18 17:18:59)

Fix gaps in flamegraph caused by incorrect timings


Patch:
diff --git a/src/adapter/10/options.ts b/src/adapter/10/options.ts
index 111445b..978d125 100644
--- a/src/adapter/10/options.ts
+++ b/src/adapter/10/options.ts
@@ -140,6 +140,8 @@ export function setupOptionsV10(
 
 	options.unmount = vnode => {
 		if (prevBeforeUnmount) prevBeforeUnmount(vnode);
+		timings.start.delete(vnode);
+		timings.end.delete(vnode);
 		renderer.onUnmount(vnode as any);
 	};
 
diff --git a/src/adapter/10/renderReason.ts b/src/adapter/10/renderReason.ts
index 1e17771..25ccd99 100644
--- a/src/adapter/10/renderReason.ts
+++ b/src/adapter/10/renderReason.ts
@@ -1,6 +1,5 @@
-import { ID } from "../../view/store/types";
 import { PreactBindings, SharedVNode } from "../shared/bindings";
-import { getVNodeId, IdMappingState } from "../shared/idMapper";
+import { IdMappingState } from "../shared/idMapper";
 import {
 	createReason,
 	getChangedKeys,
@@ -15,7 +14,7 @@ import { VNodeTimings } from "../shared/timings";
 export function getRenderReasonPost<T extends SharedVNode>(
 	ids: IdMappingState<T>,
 	bindings: PreactBindings,
-	timings: VNodeTimings<ID>,
+	timings: VNodeTimings<T>,
 	old: T | null,
 	next: T | null,
 ): RenderReasonData | null {
@@ -68,16 +67,12 @@ export function getRenderReasonPost<T extends SharedVNode>(
 	}
 
 	const parent = bindings.getVNodeParent(next) as T;
-	if (parent != null) {
-		const parentId = getVNodeId(ids, parent);
-		const nextId = getVNodeId(ids, next);
-		if (
-			parent != null &&
-			(timings.start.get(nextId) || 0) >= (timings.start.get(parentId) || 0) &&
-			(timings.end.get(nextId) || 0) <= (timings.start.get(parentId) || 0)
-		) {
-			return createReason(RenderReason.PARENT_UPDATE, null);
-		}
+	if (
+		parent != null &&
+		(timings.start.get(next) || 0) >= (timings.start.get(parent) || 0) &&
+		(timings.end.get(next) || 0) <= (timings.end.get(parent) || 0)
+	) {
+		return createReason(RenderReason.PARENT_UPDATE, null);
 	}
 
 	return createReason(RenderReason.FORCE_UPDATE, null);
diff --git a/src/adapter/hook.ts b/src/adapter/hook.ts
index 0b37bab..a962ff7 100644
--- a/src/adapter/hook.ts
+++ b/src/adapter/hook.ts
@@ -11,7 +11,6 @@ import { PROFILE_RELOAD, STATS_RELOAD } from "../constants";
 import { setupOptionsV11 } from "./11/options";
 import { newProfiler } from "./adapter/profiler";
 import { createIdMappingState } from "./shared/idMapper";
-import { VNodeTimings } from "./shared/timings";
 import { bindingsV10 } from "./10/bindings";
 import { bindingsV11 } from "./11/bindings";
 
@@ -208,11 +207,6 @@ export function createHook(port: PortPageHook): DevtoolsHook {
 			// multiple connected renderers
 			const namespace = Math.floor(Math.random() * 2 ** 32);
 
-			const timings: VNodeTimings = {
-				start: new Map(),
-				end: new Map(),
-			};
-
 			// currently we only support preact >= 10, later we can add another branch for major === 8
 			if (preactVersionMatch.major == 10) {
 				const supports = {
@@ -237,7 +231,6 @@ export function createHook(port: PortPageHook): DevtoolsHook {
 					filters,
 					idMapper,
 					bindingsV10,
-					timings,
 				);
 				setupOptionsV10(options, renderer, config as any);
 				return attachRenderer(renderer, supports);
@@ -256,7 +249,6 @@ export function createHook(port: PortPageHook): DevtoolsHook {
 					filters,
 					idMapper,
 					bindingsV11,
-					timings,
 				);
 				setupOptionsV11(options as any, renderer, config, profiler);
 				return attachRenderer(renderer, {
diff --git a/src/adapter/shared/bindings.ts b/src/adapter/shared/bindings.ts
index 04e3708..35e19e5 100644
--- a/src/adapter/shared/bindings.ts
+++ b/src/adapter/shared/bindings.ts
@@ -45,7 +45,7 @@ export interface PreactBindings<T extends SharedVNode = SharedVNode> {
 	getRenderReasonPost(
 		ids: IdMappingState<T>,
 		bindings: PreactBindings<T>,
-		timings: VNodeTimings,
+		timings: VNodeTimings<T>,
 		old: T | null,
 		next: T | null,
 	): RenderReasonData | null;
diff --git a/src/adapter/shared/renderer.ts b/src/adapter/shared/renderer.ts
index 2fd838c..9b18498 100644
--- a/src/adapter/shared/renderer.ts
+++ b/src/adapter/shared/renderer.ts
@@ -20,7 +20,6 @@ import { createCommit, shouldFilter } from "../shared/traverse";
 import { PreactBindings, SharedVNode } from "../shared/bindings";
 import { inspectVNode } from "./inspectVNode";
 import { logVNode } from "../10/log";
-import { VNodeTimings } from "./timings";
 
 export interface RendererConfig {
 	Fragment: FunctionalComponent;
@@ -67,7 +66,6 @@ export function createRenderer<T extends SharedVNode>(
 	filters: FilterState,
 	ids: IdMappingState<T>,
 	bindings: PreactBindings<T>,
-	timings: VNodeTimings,
 ): Renderer<T> {
 	const roots = new Set<T>();
 
@@ -215,7 +213,6 @@ export function createRenderer<T extends SharedVNode>(
 					config,
 					profiler,
 					bindings,
-					timings,
 					{ start: new Map(), end: new Map() },
 					null,
 				);
@@ -236,7 +233,6 @@ export function createRenderer<T extends SharedVNode>(
 				config,
 				profiler,
 				bindings,
-				timings,
 				timingsByVNode,
 				renderReasonPre,
 			);
diff --git a/src/adapter/shared/traverse.ts b/src/adapter/shared/traverse.ts
index 6981482..86dcc9c 100644
--- a/src/adapter/shared/traverse.ts
+++ b/src/adapter/shared/traverse.ts
@@ -18,7 +18,7 @@ import { NodeType } from "../../constants";
 import { getDiffType, recordComponentStats } from "./stats";
 import { measureUpdate } from "../adapter/highlightUpdates";
 import { PreactBindings, SharedVNode } from "./bindings";
-import { removeTime, VNodeTimings } from "./timings";
+import { VNodeTimings } from "./timings";
 
 function getHocName(name: string) {
 	const idx = name.indexOf("(");
@@ -172,7 +172,6 @@ function mount<T extends SharedVNode>(
 	profiler: ProfilerState,
 	hocs: string[],
 	bindings: PreactBindings<T>,
-	timings: VNodeTimings<ID>,
 	timingsByVNode: VNodeTimings<T>,
 	renderReasonPre: Map<T, RenderReasonData> | null,
 ) {
@@ -202,13 +201,6 @@ function mount<T extends SharedVNode>(
 			commit.operations.push(MsgTypes.ADD_ROOT, id);
 		}
 
-		if (!timings.start.has(id)) {
-			timings.start.set(id, timingsByVNode.start.get(vnode) || 0);
-		}
-		if (!timings.end.has(id)) {
-			timings.end.set(id, timingsByVNode.end.get(vnode) || 0);
-		}
-
 		commit.operations.push(
 			MsgTypes.ADD_VNODE,
 			id,
@@ -219,8 +211,8 @@ function mount<T extends SharedVNode>(
 			vnode.key ? getStringId(commit.strings, vnode.key) : 0,
 			// Multiply, because operations array only supports integers
 			// and would otherwise cut off floats
-			(timings.start.get(id) || 0) * 1000,
-			(timings.end.get(id) || 0) * 1000,
+			(timingsByVNode.start.get(vnode) || 0) * 1000,
+			(timingsByVNode.end.get(vnode) || 0) * 1000,
 		);
 
 		if (hocs.length > 0) {
@@ -266,7 +258,6 @@ function mount<T extends SharedVNode>(
 				profiler,
 				hocs,
 				bindings,
-				timings,
 				timingsByVNode,
 				renderReasonPre,
 			);
@@ -322,7 +313,6 @@ function update<T extends SharedVNode>(
 	profiler: ProfilerState,
 	hocs: string[],
 	bindings: PreactBindings<T>,
-	timings: VNodeTimings<ID>,
 	timingsByVNode: VNodeTimings<T>,
 	renderReasonPre: Map<T, RenderReasonData> | null,
 ) {
@@ -355,7 +345,6 @@ function update<T extends SharedVNode>(
 					profiler,
 					hocs,
 					bindings,
-					timings,
 					timingsByVNode,
 					renderReasonPre,
 				);
@@ -381,7 +370,6 @@ function update<T extends SharedVNode>(
 			profiler,
 			hocs,
 			bindings,
-			timings,
 			timingsByVNode,
 			renderReasonPre,
 		);
@@ -389,42 +377,51 @@ function update<T extends SharedVNode>(
 	}
 
 	const id = getVNodeId(ids, vnode);
-	commit.operations.push(
-		MsgTypes.UPDATE_VNODE_TIMINGS,
-		id,
-		(timings.start.get(id) || 0) * 1000,
-		(timings.end.get(id) || 0) * 1000,
-	);
-
-	const name = bindings.getDisplayName(vnode, config);
-	const hoc = getHocName(name);
-	if (hoc) {
-		hocs = [...hocs, hoc];
-	} else {
-		addHocs(commit, id, hocs);
-		hocs = [];
-	}
-
 	const oldVNode = getVNodeById(ids, id);
 	updateVNodeId(ids, id, vnode);
 
-	if (profiler.isProfiling && profiler.captureRenderReasons) {
-		const reason =
-			renderReasonPre !== null
-				? renderReasonPre.get(vnode) || null
-				: bindings.getRenderReasonPost(ids, bindings, timings, oldVNode, vnode);
-		if (reason !== null) {
-			const count = reason.items ? reason.items.length : 0;
-			commit.operations.push(MsgTypes.RENDER_REASON, id, reason.type, count);
-			if (reason.items && count > 0) {
-				commit.operations.push(
-					...reason.items.map(str => getStringId(commit.strings, str)),
-				);
+	const didRender = timingsByVNode.end.has(vnode);
+	if (didRender) {
+		commit.operations.push(
+			MsgTypes.UPDATE_VNODE_TIMINGS,
+			id,
+			(timingsByVNode.start.get(vnode) || 0) * 1000,
+			(timingsByVNode.end.get(vnode) || 0) * 1000,
+		);
+
+		const name = bindings.getDisplayName(vnode, config);
+		const hoc = getHocName(name);
+		if (hoc) {
+			hocs = [...hocs, hoc];
+		} else {
+			addHocs(commit, id, hocs);
+			hocs = [];
+		}
+
+		if (profiler.isProfiling && profiler.captureRenderReasons) {
+			const reason =
+				renderReasonPre !== null
+					? renderReasonPre.get(vnode) || null
+					: bindings.getRenderReasonPost(
+							ids,
+							bindings,
+							timingsByVNode,
+							oldVNode,
+							vnode,
+					  );
+			if (reason !== null) {
+				const count = reason.items ? reason.items.length : 0;
+				commit.operations.push(MsgTypes.RENDER_REASON, id, reason.type, count);
+				if (reason.items && count > 0) {
+					commit.operations.push(
+						...reason.items.map(str => getStringId(commit.strings, str)),
+					);
+				}
 			}
 		}
-	}
 
-	updateHighlight(profiler, vnode, bindings);
+		updateHighlight(profiler, vnode, bindings);
+	}
 
 	const oldChildren = oldVNode
 		? bindings
@@ -441,7 +438,6 @@ function update<T extends SharedVNode>(
 		if (child == null) {
 			const oldChildId = oldChildren[i];
 			if (oldChildId != null) {
-				removeTime(timings, oldChildId);
 				commit.unmountIds.push(oldChildId);
 			}
 		} else if (
@@ -463,7 +459,6 @@ function update<T extends SharedVNode>(
 				profiler,
 				hocs,
 				bindings,
-				timings,
 				timingsByVNode,
 				renderReasonPre,
 			);
@@ -485,7 +480,6 @@ function update<T extends SharedVNode>(
 				profiler,
 				hocs,
 				bindings,
-				timings,
 				timingsByVNode,
 				renderReasonPre,
 			);
@@ -535,7 +529,6 @@ export function createCommit<T extends SharedVNode>(
 	config: RendererConfig,
 	profiler: ProfilerState,
 	helpers: PreactBindings<T>,
-	timings: VNodeTimings<ID>,
 	timingsByVNode: VNodeTimings<T>,
 	renderReasonPre: Map<T, RenderReasonData> | null,
 ): Commit {
@@ -577,7 +570,6 @@ export function createCommit<T extends SharedVNode>(
 			profiler,
 			[],
 			helpers,
-			timings,
 			timingsByVNode,
 			renderReasonPre,
 		);
@@ -593,7 +585,6 @@ export function createCommit<T extends SharedVNode>(
 			profiler,
 			[],
 			helpers,
-			timings,
 			timingsByVNode,
 			renderReasonPre,
 		);
diff --git a/src/view/components/profiler/data/commits.ts b/src/view/components/profiler/data/commits.ts
index e2f2150..a4b7975 100644
--- a/src/view/components/profiler/data/commits.ts
+++ b/src/view/components/profiler/data/commits.ts
@@ -209,61 +209,56 @@ export function recordProfilerCommit(
 	rendered: Set<ID>,
 	commitRootId: number,
 ) {
-	const commitRoot = tree.get(commitRootId)!;
-
 	const nodes = new Map<ID, DevNode>();
 
 	// The time of the node that took the longest to render
 	let maxSelfDuration = 0;
-	const selfDurations = new Map<ID, number>();
-
 	let totalCommitDuration = 0;
-	const start = commitRoot.startTime;
+	const selfDurations = new Map<ID, number>();
 
-	let stack = [commitRootId];
-	let item;
-	while ((item = stack.pop())) {
-		const node = tree.get(item);
-		if (!node) continue;
-		nodes.set(node.id, node);
+	const rootId = getRoot(tree, commitRootId);
 
-		if (node.startTime >= start) {
-			const next = node.endTime - commitRoot.startTime;
-			if (next >= totalCommitDuration) {
-				totalCommitDuration = next;
-			}
+	// Find previous commit to copy over timing data later
+	const commits = profiler.commits.$;
+	let prevCommit: CommitData | undefined;
+	for (let i = commits.length - 1; i >= 0; i--) {
+		if (commits[i].rootId === rootId) {
+			prevCommit = commits[i];
+			break;
 		}
+	}
 
-		let selfDuration = node.endTime - node.startTime;
-
-		for (let i = 0; i < node.children.length; i++) {
-			const childId = node.children[i];
-			if (!rendered.has(childId)) continue;
+	// Traverse nodes from the actual root to be able to build
+	// the full tree.
+	const stack = [rootId];
+	let id: ID | undefined;
+	while ((id = stack.pop())) {
+		const node = tree.get(id);
+		if (!node) continue;
 
-			const child = tree.get(childId)!;
+		if (rendered.has(node.id)) {
+			// Collect the time a node took to render excluding its children
+			let selfDuration = node.endTime - node.startTime;
+			for (let i = 0; i < node.children.length; i++) {
+				const childId = node.children[i];
 
-			if (child.startTime > node.startTime) {
-				selfDuration -= child.endTime - child.startTime;
+				if (rendered.has(childId)) {
+					const child = tree.get(childId)!;
+					selfDuration -= child.endTime - child.startTime;
+				}
 			}
 
-			stack.push(childId);
-		}
-
-		if (selfDuration > maxSelfDuration) {
-			maxSelfDuration = selfDuration;
+			if (selfDuration > maxSelfDuration) {
+				maxSelfDuration = selfDuration;
+			}
+			totalCommitDuration += selfDuration;
+			selfDurations.set(node.id, selfDuration);
+		} else if (prevCommit) {
+			// Otherwise just copy over the duration from the previous commit
+			// of that root id.
+			selfDurations.set(node.id, prevCommit.selfDurations.get(node.id) || 0);
 		}
 
-		selfDurations.set(node.id, selfDuration);
-	}
-
-	// Traverse nodes not part of the current commit
-	const rootId = getRoot(tree, commitRootId);
-	stack = [rootId];
-	while ((item = stack.pop())) {
-		if (item === commitRootId) continue;
-
-		const node = tree.get(item);
-		if (!node) continue;
 		nodes.set(node.id, node);
 
 		stack.push(...node.children);
diff --git a/src/view/components/profiler/flamegraph/FlameGraph.module.css b/src/view/components/profiler/flamegraph/FlameGraph.module.css
index e096f2c..42f9658 100644
--- a/src/view/components/profiler/flamegraph/FlameGraph.module.css
+++ b/src/view/components/profiler/flamegraph/FlameGraph.module.css
@@ -4,6 +4,7 @@
 	height: 100%;
 	position: relative;
 	overflow-x: hidden;
+	scrollbar-gutter: stable;
 }
 
 .node {
diff --git a/src/view/components/profiler/flamegraph/FlameGraph.tsx b/src/view/components/profiler/flamegraph/FlameGraph.tsx
index 91872ed..b7493a3 100644
--- a/src/view/components/profiler/flamegraph/FlameGraph.tsx
+++ b/src/view/components/profiler/flamegraph/FlameGraph.tsx
@@ -1,4 +1,4 @@
-import { h } from "preact";
+import { Fragment, h } from "preact";
 import s from "./FlameGraph.module.css";
 import { useStore, useObserver } from "../../../store/react-bindings";
 import { useRef, useCallback, useState, useEffect } from "preact/hooks";
@@ -18,7 +18,7 @@ const highlightNode = debounce(
 
 export function FlameGraph() {
 	const store = useStore();
-	const [canvasWidth, setCanvasWidth] = useState(600);
+	const [canvasWidth, setCanvasWidth] = useState(-1);
 
 	const displayType = useObserver(() => store.profiler.flamegraphType.$);
 	const selected = useObserver(() => store.profiler.selectedNode.$ || EMPTY);
@@ -29,14 +29,16 @@ export function FlameGraph() {
 	const ref = useRef<HTMLDivElement>();
 	useEffect(() => {
 		if (ref.current) {
-			setCanvasWidth(ref.current.clientWidth);
+			// Pad for potential rounding issues
+			setCanvasWidth(Math.floor(ref.current.clientWidth) - 4);
 		}
-	}, [isRecording || !commit]);
+	}, [isRecording, commit]);
 	useResize(() => {
 		if (ref.current) {
-			setCanvasWidth(ref.current.clientWidth);
+			// Pad for potential rounding issues
+			setCanvasWidth(Math.floor(ref.current.clientWidth) - 4);
 		}
-	}, []);
+	}, [commit]);
 
 	const onSelect = useCallback(
 		(id: number) => {
@@ -63,24 +65,28 @@ export function FlameGraph() {
 			data-type={displayType.toLowerCase()}
 			style={showDebug ? "overflow-x: auto" : ""}
 		>
-			{displayType === FlamegraphType.RANKED ? (
-				<RankedLayout
-					canvasWidth={canvasWidth}
-					commit={commit!}
-					onSelect={onSelect}
-					selected={selected}
-					onMouseEnter={onMouseEnter}
-					onMouseLeave={onMouseLeave}
-				/>
-			) : (
-				<FlamegraphLayout
-					canvasWidth={canvasWidth}
-					commit={commit!}
-					onSelect={onSelect}
-					selected={selected}
-					onMouseEnter={onMouseEnter}
-					onMouseLeave={onMouseLeave}
-				/>
+			{canvasWidth === -1 ? null : (
+				<Fragment>
+					{displayType === FlamegraphType.RANKED ? (
+						<RankedLayout
+							canvasWidth={canvasWidth}
+							commit={commit!}
+							onSelect={onSelect}
+							selected={selected}
+							onMouseEnter={onMouseEnter}
+							onMouseLeave={onMouseLeave}
+						/>
+					) : (
+						<FlamegraphLayout
+							canvasWidth={canvasWidth}
+							commit={commit!}
+							onSelect={onSelect}
+							selected={selected}
+							onMouseEnter={onMouseEnter}
+							onMouseLeave={onMouseLeave}
+						/>
+					)}
+				</Fragment>
 			)}
 		</div>
 	);
diff --git a/src/view/components/profiler/flamegraph/FlameNode.tsx b/src/view/components/profiler/flamegraph/FlameNode.tsx
index b30f4fc..a7cc8a8 100644
--- a/src/view/components/profiler/flamegraph/FlameNode.tsx
+++ b/src/view/components/profiler/flamegraph/FlameNode.tsx
@@ -32,7 +32,7 @@ export function FlameNode(props: Props) {
 
 	if (visible) {
 		const y = node.row * ROW_HEIGHT;
-		widthCss.current = `width: ${Math.max(width, 2)}px;`;
+		widthCss.current = `width: ${width}px;`;
 		transform.current = `transform: translate3d(${x}px, ${y}px, 0);`;
 	}
 
diff --git a/src/view/components/profiler/flamegraph/modes/FlamegraphLayout.tsx b/src/view/components/profiler/flamegraph/modes/FlamegraphLayout.tsx
index 4055f9f..700ae4c 100644
--- a/src/view/components/profiler/flamegraph/modes/FlamegraphLayout.tsx
+++ b/src/view/components/profiler/flamegraph/modes/FlamegraphLayout.tsx
@@ -7,7 +7,6 @@ import { placeFlamegraph } from "./flamegraph-utils";
 import { formatTime } from "../../util";
 import { useObserver, useStore } from "../../../../store/react-bindings";
 import { HocLabels } from "../../../elements/TreeView";
-import { getSelfDuration } from "./patchTree";
 
 export interface FlamegraphLayoutProps {
 	commit: CommitData;
@@ -48,7 +47,7 @@ export function FlamegraphLayout({
 				const node = commit.nodes.get(pos.id)!;
 				let appendix = "";
 				if (!pos.commitParent && pos.weight !== -1) {
-					const self = formatTime(getSelfDuration(commit, node));
+					const self = formatTime(commit.selfDurations.get(pos.id) || 0);
 					const total = formatTime(node.endTime - node.startTime);
 					appendix = ` (${self} of ${total})`;
 				}
diff --git a/src/view/components/profiler/flamegraph/modes/flamegraph-utils.ts b/src/view/components/profiler/flamegraph/modes/flamegraph-utils.ts
index 19c8abb..dbe16b9 100644
--- a/src/view/components/profiler/flamegraph/modes/flamegraph-utils.ts
+++ b/src/view/components/profiler/flamegraph/modes/flamegraph-utils.ts
@@ -55,20 +55,32 @@ export function placeFlamegraph(
 	return Array.from(idToTransform.values()).map(pos => {
 		let start;
 		let width;
+		let hidden = false;
 		if (maximizedIds.has(pos.id)) {
 			start = 0;
 			width = canvasWidth;
 		} else {
 			start = (pos.x - offset) * scale;
 			width = pos.width * scale;
+
+			// Hide other sibling nodes of maximized nodes
+			const node = tree.get(pos.id)!;
+			if (node && node.parent !== -1 && maximizedIds.has(node.parent)) {
+				const parent = tree.get(node.parent)!;
+				if (parent.children.length > 1) {
+					hidden = parent.children.some(childId => maximizedIds.has(childId));
+				}
+			}
 		}
 
+		const visible = hidden ? false : start >= 0 && start <= canvasWidth;
+
 		return {
 			...pos,
 			x: start,
 			width,
 			maximized: maximizedIds.has(pos.id),
-			visible: start >= 0 && start <= canvasWidth,
+			visible,
 		};
 	});
 }
diff --git a/src/view/components/profiler/flamegraph/modes/patchTree.ts b/src/view/components/profiler/flamegraph/modes/patchTree.ts
index d5e1d64..4c73513 100644
--- a/src/view/components/profiler/flamegraph/modes/patchTree.ts
+++ b/src/view/components/profiler/flamegraph/modes/patchTree.ts
@@ -14,7 +14,7 @@ export function patchTree(commit: CommitData) {
 	const { nodes, commitRootId } = commit;
 
 	const idToTransform = new Map<ID, NodeTransform>();
-	placeNode(commit, idToTransform, commit.rootId);
+	placeNode(commit, idToTransform, commit.rootId, 0);
 
 	let direct: DevNode | undefined = nodes.get(commitRootId);
 	if (direct !== undefined) {
@@ -29,27 +29,11 @@ export function patchTree(commit: CommitData) {
 	return idToTransform;
 }
 
-export function getSelfDuration(commit: CommitData, node: DevNode) {
-	const duration = node.endTime - node.startTime;
-	const children = node.children.reduce((acc, childId) => {
-		if (!commit.rendered.has(childId)) {
-			return acc;
-		}
-
-		const child = commit.nodes.get(childId);
-		if (!child) return 0;
-
-		return acc + (child.endTime - child.startTime);
-	}, 0);
-
-	const selfDuration = duration - children;
-	return selfDuration <= 0 ? 0 : selfDuration;
-}
-
 function placeNode(
 	commit: CommitData,
 	idToTransform: Map<ID, NodeTransform>,
 	id: ID,
+	depth: number,
 ) {
 	const node = commit.nodes.get(id)!;
 	if (!node) return;
@@ -60,11 +44,11 @@ function placeNode(
 		start = parentPos.x + parentPos.width;
 	}
 
-	const selfDuration = getSelfDuration(commit, node);
+	const selfDuration = commit.selfDurations.get(id) || 0;
 
 	const nodePos: NodeTransform = {
 		id,
-		row: node.depth,
+		row: depth,
 		x: start,
 		commitParent: false,
 		maximized: false,
@@ -74,21 +58,34 @@ function placeNode(
 	};
 	idToTransform.set(id, nodePos);
 
+	// Enlarge to make node visible if width == 0
+	if (nodePos.width === 0) {
+		enlargeParents(commit, idToTransform, id, 0.01);
+	}
+
 	for (let i = 0; i < node.children.length; i++) {
 		const childId = node.children[i];
-		const child = commit.nodes.get(childId);
-		if (!child) continue;
 
-		const selfDuration = getSelfDuration(commit, child);
+		// TODO: Handle static trees differently
+		const childSelfDuration = commit.selfDurations.get(childId) || 0;
 
-		placeNode(commit, idToTransform, childId);
+		placeNode(commit, idToTransform, childId, depth + 1);
 
 		// Expand parents upwards by self duration
-		let parentId = id;
-		while (parentId !== -1) {
-			const parent = commit.nodes.get(parentId)!;
-			idToTransform.get(parentId)!.width += selfDuration;
-			parentId = parent.parent;
-		}
+		enlargeParents(commit, idToTransform, id, childSelfDuration);
+	}
+}
+
+function enlargeParents(
+	commit: CommitData,
+	idToTransform: Map<ID, NodeTransform>,
+	id: ID,
+	value: number,
+) {
+	let parentId = id;
+	while (parentId !== -1) {
+		const parent = commit.nodes.get(parentId)!;
+		idToTransform.get(parentId)!.width += value;
+		parentId = parent.parent;
 	}
 }


Test patch:
diff --git a/src/adapter/shared/renderer.test.tsx b/src/adapter/shared/renderer.test.tsx
index b292405..7f99e79 100644
--- a/src/adapter/shared/renderer.test.tsx
+++ b/src/adapter/shared/renderer.test.tsx
@@ -31,10 +31,6 @@ export function setupMockHook(options: Options) {
 		{ type: new Set(), regex: [] },
 		createIdMappingState(1, bindingsV10.getInstance),
 		bindingsV10,
-		{
-			start: new Map(),
-			end: new Map(),
-		},
 	);
 	const destroy = setupOptionsV10(options, renderer, {
 		Fragment: Fragment as any,
@@ -77,7 +73,6 @@ describe("Renderer 10", () => {
 		expect(toSnapshot(spy.args[1][1])).to.deep.equal([
 			"rootId: 1",
 			"Update timings 1",
-			"Update timings 2",
 		]);
 	});
 
@@ -111,7 +106,6 @@ describe("Renderer 10", () => {
 			"rootId: 1",
 			"Add 3 <span> to parent 2",
 			"Update timings 1",
-			"Update timings 2",
 		]);
 	});
 
@@ -154,7 +148,6 @@ describe("Renderer 10", () => {
 		expect(toSnapshot(spy.args[1][1])).to.deep.equal([
 			"rootId: 1",
 			"Update timings 1",
-			"Update timings 2",
 		]);
 	});
 
@@ -177,9 +170,6 @@ describe("Renderer 10", () => {
 		expect(toSnapshot(spy.args[1][1])).to.deep.equal([
 			"rootId: 1",
 			"Update timings 1",
-			"Update timings 2",
-			"Update timings 4",
-			"Update timings 3",
 			"Reorder 2 [4, 3]",
 		]);
 	});
@@ -343,7 +333,6 @@ describe("Renderer 10", () => {
 			expect(toSnapshot(spy.args[1][1])).to.deep.equal([
 				"rootId: 2",
 				"Update timings 2",
-				"Update timings 3",
 			]);
 		});
 
diff --git a/src/view/components/profiler/flamegraph/modes/patchTree.test.ts b/src/view/components/profiler/flamegraph/modes/patchTree.test.ts
index 36e0c4c..685ad86 100644
--- a/src/view/components/profiler/flamegraph/modes/patchTree.test.ts
+++ b/src/view/components/profiler/flamegraph/modes/patchTree.test.ts
@@ -4,6 +4,10 @@ import { flames } from "../testHelpers";
 import { Tree, DevNode, ID } from "../../../../store/types";
 import { NodeTransform } from "../shared";
 
+function round(n: number) {
+	return Math.round((n + Number.EPSILON) * 100) / 100;
+}
+
 export function toTimings(tree: Tree, flame: Map<ID, NodeTransform>) {
 	return Array.from(flame.values())
 		.map(pos => {
@@ -12,24 +16,26 @@ export function toTimings(tree: Tree, flame: Map<ID, NodeTransform>) {
 				id: pos.id,
 				name: node.name,
 				children: node.children,
-				start: pos.x,
-				end: pos.x + pos.width,
+				start: round(pos.x),
+				end: round(pos.x + pos.width),
 			};
 		})
 		.sort((a, b) => a.id - b.id);
 }
 
-export function patch(tree: Tree, rootId: ID, commitRootId: ID) {
+export function patch(
+	data: ReturnType<typeof flames>,
+	rootId: ID,
+	commitRootId: ID,
+) {
 	return toTimings(
-		tree,
+		data.idMap,
 		patchTree({
-			nodes: tree,
+			...data.commit,
 			rootId,
-			rendered: new Set(tree.keys()),
 			commitRootId,
 			duration: 100,
 			maxSelfDuration: 100,
-			selfDurations: new Map(),
 		}),
 	);
 }
@@ -54,11 +60,6 @@ export function prepareFixture(fixture: {
 }
 
 describe("patchTree", () => {
-	it("should return work with no data", () => {
-		const res = patch(new Map(), 1, 1);
-		expect(res.length).to.equal(0);
-	});
-
 	it("should offset timings if tree is new", () => {
 		const b = flames`
 			App *******
@@ -70,7 +71,7 @@ describe("patchTree", () => {
 			x.endTime += 200;
 		});
 
-		const actual = patch(b.idMap, 1, 1);
+		const actual = patch(b, 1, 1);
 		expect(actual).to.deep.equal([
 			{ name: "App", id: 1, start: 0, end: 110, children: [2] },
 			{ name: "Bar", id: 2, start: 50, end: 110, children: [] },
@@ -84,11 +85,11 @@ describe("patchTree", () => {
 			  Bob *
 		`;
 
-		const actual = patch(tree.idMap, 1, 2);
+		const actual = patch(tree, 1, 2);
 		expect(actual).to.deep.equal([
-			{ name: "App", id: 1, start: 0, end: 70, children: [2] },
-			{ name: "Bar", id: 2, start: 0, end: 70, children: [3] },
-			{ name: "Bob", id: 3, start: 20, end: 70, children: [] },
+			{ name: "App", id: 1, start: 0, end: 70.01, children: [2] },
+			{ name: "Bar", id: 2, start: 0.01, end: 70.01, children: [3] },
+			{ name: "Bob", id: 3, start: 20.01, end: 70.01, children: [] },
 		]);
 	});
 
@@ -106,7 +107,7 @@ describe("patchTree", () => {
 			}
 		});
 
-		const actual = patch(b.idMap, 1, 1);
+		const actual = patch(b, 1, 1);
 		expect(actual).to.deep.equal([
 			// TODO: App is detected as a static tree
 			{ name: "App", id: 1, start: 0, end: 90, children: [2] },
@@ -124,7 +125,7 @@ describe("patchTree", () => {
 		b.byName("Bob")!.startTime -= 40;
 		b.byName("Bob")!.endTime -= 40;
 
-		const actual = patch(b.idMap, 1, 2);
+		const actual = patch(b, 1, 2);
 		expect(actual).to.deep.equal([
 			{
 				name: "App",
@@ -150,10 +151,10 @@ describe("patchTree", () => {
 			 Bar *****
 		`;
 
-		const actual = patch(a.idMap, 1, 2);
+		const actual = patch(a, 1, 2);
 		expect(actual).to.deep.equal([
-			{ name: "App", id: 1, start: 0, end: 90, children: [2] },
-			{ name: "Bar", id: 2, start: 0, end: 90, children: [] },
+			{ name: "App", id: 1, start: 0, end: 90.01, children: [2] },
+			{ name: "Bar", id: 2, start: 0.01, end: 90.01, children: [] },
 		]);
 	});
 
@@ -171,7 +172,7 @@ describe("patchTree", () => {
 			}
 		});
 
-		const actual = patch(b.idMap, 1, 1);
+		const actual = patch(b, 1, 1);
 		expect(actual).to.deep.equal([
 			{ name: "App", id: 1, start: 0, end: 90, children: [2] },
 			{ name: "Bar", id: 2, start: 20, end: 90, children: [3] },
diff --git a/src/view/components/profiler/flamegraph/testHelpers.ts b/src/view/components/profiler/flamegraph/testHelpers.ts
index 209cf51..8aae001 100644
--- a/src/view/components/profiler/flamegraph/testHelpers.ts
+++ b/src/view/components/profiler/flamegraph/testHelpers.ts
@@ -148,7 +148,7 @@ export function flames(
 			child.parent = node.id;
 		});
 
-		selfDurations.set(node.id, selfDuration);
+		selfDurations.set(node.id, Math.max(0, selfDuration));
 	});
 
 	// Correct dangling children
diff --git a/test-e2e/test-utils.ts b/test-e2e/test-utils.ts
index b559024..1aa16b2 100644
--- a/test-e2e/test-utils.ts
+++ b/test-e2e/test-utils.ts
@@ -243,6 +243,7 @@ export type DevtoolsTab = "ELEMENTS" | "PROFILER" | "STATISTICS" | "SETTINGS";
 
 // Preact Devtools specific functions
 export async function clickTab(page: Page, tab: DevtoolsTab) {
+	await waitForSelector(page, '[name="root-panel"][value]');
 	await page.evaluate(name => {
 		return document
 			.querySelector(`[name="root-panel"][value="${name}"]`)!
diff --git a/test-e2e/tests/profiler/flamegraph/highlight-flamegraph.test.ts b/test-e2e/tests/profiler/flamegraph/highlight-flamegraph.test.ts
index 8872ee7..6fcd64b 100644
--- a/test-e2e/tests/profiler/flamegraph/highlight-flamegraph.test.ts
+++ b/test-e2e/tests/profiler/flamegraph/highlight-flamegraph.test.ts
@@ -6,6 +6,7 @@ import {
 } from "../../../test-utils";
 import { expect } from "chai";
 import { wait } from "pentf/utils";
+import { waitForSelector } from "pentf/browser_utils";
 
 export const description = "Should highlight flamegraph node if present in DOM";
 
@@ -18,7 +19,9 @@ export async function run(config: any) {
 	await click(page, "button");
 	await clickRecordButton(devtools);
 
-	await devtools.hover('[data-type="flamegraph"] [data-name="Counter"]');
+	const selector = '[data-type="flamegraph"] [data-name="Counter"]';
+	await waitForSelector(devtools, selector);
+	await devtools.hover(selector);
 
 	// Wait for possible flickering to occur
 	await wait(1000);
diff --git a/test-e2e/tests/profiler/flamegraph/utils.ts b/test-e2e/tests/profiler/flamegraph/utils.ts
index ba92e03..9320439 100644
--- a/test-e2e/tests/profiler/flamegraph/utils.ts
+++ b/test-e2e/tests/profiler/flamegraph/utils.ts
@@ -1,7 +1,10 @@
 import { Page } from "puppeteer";
+import { waitForSelector } from "../../../test-utils";
 
 export async function getFlameNodes(page: Page) {
-	return await page.$$eval('[data-type="flamegraph"] > *', els => {
+	const selector = '[data-type="flamegraph"] > *';
+	await waitForSelector(page, selector);
+	return await page.$$eval(selector, els => {
 		return els.map(el => {
 			return {
 				maximized: el.hasAttribute("data-maximized"),