Repository: preactjs/preact-devtools
Pull request #187 (2020-05-07 06:26:24)

Profiler refactor Part 1
This PR cleans up a few long standing issues with the Profiler.

- Nodes overlapping (not completely gone, but happens a lot less)
- Window resize could mess up node positions in certain situations
- Rendered at panel pointed at wrong commits when a node is included in more than one commit
- Visual improvements (mostly misaligned text)
- Make Flamegraph view even more performant on slow GPUs (very noticeable on my Dell XPS 13)
- Improve Profiler debugging experience

There is more to be done, but this is already such an improvement over the current implementation that it's worth to release it. Future refactoring ideas:

- Split node placement
  - Flamegraph view must always be based on true node hierarchy
  - Ranked view can use a quicker implementation


Patch:
diff --git a/package.json b/package.json
index 8fd83b5..4e1b935 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
 	"devDependencies": {
 		"@babel/core": "^7.9.0",
 		"@babel/plugin-transform-react-jsx": "^7.9.4",
+		"@rollup/plugin-alias": "^3.1.0",
 		"@rollup/plugin-replace": "^2.3.2",
 		"@testing-library/preact": "^1.0.2",
 		"@types/archiver": "^3.1.0",
diff --git a/rollup.config.js b/rollup.config.js
index bcf3185..1f1557a 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -5,6 +5,7 @@ import resolve from "rollup-plugin-node-resolve";
 import postcss from "rollup-plugin-postcss";
 import commonjs from "rollup-plugin-commonjs";
 import replace from "@rollup/plugin-replace";
+import alias from "@rollup/plugin-alias";
 import path from "path";
 
 const BROWSERS = ["chrome", "edge", "firefox", "inline"].filter(x => {
@@ -112,6 +113,16 @@ export default entries.map(data => ({
 				return comment + code;
 			},
 		},
+		process.env.DEBUG === "true" &&
+			alias({
+				entries: [
+					{
+						find: "preact/hooks",
+						replacement: "./node_modules/preact/hooks/src/index.js",
+					},
+					{ find: "preact", replacement: "./node_modules/preact/src/index.js" },
+				],
+			}),
 		resolve(),
 		commonjs(),
 		replace({
diff --git a/src/adapter/events/operations.ts b/src/adapter/events/operations.ts
index ca57524..2be916c 100644
--- a/src/adapter/events/operations.ts
+++ b/src/adapter/events/operations.ts
@@ -55,9 +55,14 @@ export function ops2Tree(oldTree: Tree, ops: number[]) {
 				const id = ops[i + 1];
 				if (!pending.has(id)) {
 					const node = oldTree.get(id)!;
-					pending.set(id, deepClone(node));
+					try {
+						pending.set(id, deepClone(node));
+					} catch (err) {
+						// eslint-disable-next-line no-console
+						console.error(`Missing node ${id} detected.`);
+						throw err;
+					}
 				}
-
 				const x = pending.get(id)!;
 				x.startTime = ops[i + 2] / 1000;
 				x.endTime = ops[i + 3] / 1000;
diff --git a/src/shells/shared/panel/panel.ts b/src/shells/shared/panel/panel.ts
index 2d7f235..f69a809 100644
--- a/src/shells/shared/panel/panel.ts
+++ b/src/shells/shared/panel/panel.ts
@@ -35,6 +35,10 @@ async function initDevtools() {
 	store.theme.on(v => storeTheme(v));
 	store.profiler.captureRenderReasons.on(v => storeCaptureRenderReasons(v));
 
+	if (process.env.DEBUG) {
+		(window as any).store = store;
+	}
+
 	// Render our application
 	const root = window.document.getElementById("root")!;
 	render(h(DevTools, { store, window }), root);
@@ -52,14 +56,13 @@ port.onDisconnect.addListener(() => {
 
 // Subscribe to messages from content script
 port.onMessage.addListener(async message => {
-	debug("> message", message);
-
 	if (!initialized) {
 		debug("initialize devtools panel");
 		await initDevtools();
 	}
 
 	if (message.type === "init") {
+		debug("> message", message);
 		port.postMessage({
 			type: "init",
 			tabId: chrome.devtools.inspectedWindow.tabId,
diff --git a/src/view/components/profiler/components/CommitTimeline/CommitTimeline.tsx b/src/view/components/profiler/components/CommitTimeline/CommitTimeline.tsx
index bef95ed..ac857e7 100644
--- a/src/view/components/profiler/components/CommitTimeline/CommitTimeline.tsx
+++ b/src/view/components/profiler/components/CommitTimeline/CommitTimeline.tsx
@@ -91,32 +91,30 @@ export function CommitTimeline(props: CommitTimelineProps) {
 	const paneContainerRef = useRef<HTMLDivElement>();
 
 	useEffect(() => {
-		const sizes = calcSize(
-			container,
-			inner,
-			pane,
-			paneContainerRef,
-			items.length,
-			selected,
-		);
-		setPaneWidth(sizes.pane);
-		setViewportWidth(sizes.viewport);
-		setOffset(sizes.offset);
-	}, [pane, inner, pane, paneContainerRef, items.length, selected]);
-
-	useResize(() => {
-		const sizes = calcSize(
-			container,
-			inner,
-			pane,
-			paneContainerRef,
-			items.length,
-			selected,
-		);
-		setPaneWidth(sizes.pane);
-		setViewportWidth(sizes.viewport);
-		setOffset(sizes.offset);
-	}, [pane, container, paneContainerRef, inner, items.length, selected]);
+		if (pane.current) {
+			const active = pane.current.querySelector("[data-selected]");
+			// JSDOM doesn't support scrollIntoView
+			if (active && active.scrollIntoView) active.scrollIntoView();
+		}
+	}, [selected]);
+
+	useResize(
+		() => {
+			const sizes = calcSize(
+				container,
+				inner,
+				pane,
+				paneContainerRef,
+				items.length,
+				selected,
+			);
+			setPaneWidth(sizes.pane);
+			setViewportWidth(sizes.viewport);
+			setOffset(sizes.offset);
+		},
+		[pane, container, paneContainerRef, inner, items.length, selected],
+		true,
+	);
 
 	const onPrev = useCallback(() => {
 		const next = selected - 1;
diff --git a/src/view/components/profiler/components/RenderedAt/RenderedAt.tsx b/src/view/components/profiler/components/RenderedAt/RenderedAt.tsx
index c4d7e7d..3aa517a 100644
--- a/src/view/components/profiler/components/RenderedAt/RenderedAt.tsx
+++ b/src/view/components/profiler/components/RenderedAt/RenderedAt.tsx
@@ -3,30 +3,28 @@ import { useStore, useObserver } from "../../../../store/react-bindings";
 import { SidebarPanel, Empty } from "../../../sidebar/SidebarPanel";
 import s from "./RenderedAt.css";
 import { formatTime } from "../../util";
-import { mapParents } from "../../flamegraph/transform/util";
 
 export function RenderedAt() {
 	const store = useStore();
 	const data = useObserver(() => {
 		const id = store.profiler.selectedNodeId.$;
 
-		return store.profiler.commits.$.filter(x => x.nodes.has(id))
-			.filter(x => {
-				if (id === x.commitRootId) return true;
-				let keep = false;
-				mapParents(x.nodes, id, node => {
-					if (node.id === x.commitRootId) keep = true;
-				});
-				return keep;
-			})
-			.map((commit, commitIdx) => {
-				const node = commit.nodes.get(id)!;
-				return {
-					id: commitIdx,
-					startTime: node.startTime,
-					selfDuration: node.selfDuration,
-				};
-			});
+		return store.profiler.commits.$.filter(x => {
+			if (x.nodes.has(id)) {
+				const node = x.nodes.get(id)!;
+				const root = x.nodes.get(x.commitRootId)!;
+				return node.startTime >= root.startTime && node.endTime <= root.endTime;
+			}
+
+			return false;
+		}).map(commit => {
+			const node = commit.nodes.get(id)!;
+			return {
+				index: store.profiler.commits.$.findIndex(x => x === commit),
+				startTime: node.startTime,
+				selfDuration: node.selfDuration,
+			};
+		});
 	});
 
 	const commitIdx = useObserver(() => store.profiler.activeCommitIdx.$);
@@ -38,14 +36,14 @@ export function RenderedAt() {
 			{data.length <= 0 ? (
 				<Empty>Did not render during this profiling session</Empty>
 			) : (
-				<nav>
+				<nav data-testid="rendered-at">
 					{data.map(node => {
 						return (
 							<button
-								key={node.id}
+								key={node.index}
 								class={s.item}
-								data-active={commitIdx === node.id}
-								onClick={() => (store.profiler.activeCommitIdx.$ = node.id)}
+								data-active={commitIdx === node.index}
+								onClick={() => (store.profiler.activeCommitIdx.$ = node.index)}
 							>
 								<span>
 									{formatTime(node.startTime)} for{" "}
diff --git a/src/view/components/profiler/components/TimelineBar/TimelineBar.tsx b/src/view/components/profiler/components/TimelineBar/TimelineBar.tsx
index ef14ef8..4c78b93 100644
--- a/src/view/components/profiler/components/TimelineBar/TimelineBar.tsx
+++ b/src/view/components/profiler/components/TimelineBar/TimelineBar.tsx
@@ -21,7 +21,13 @@ export function TimelineBar() {
 
 	const onCommitChange = useCallback(
 		(n: number) => {
-			store.profiler.activeCommitIdx.$ = n;
+			const { activeCommitIdx, selectedNodeId, activeCommit } = store.profiler;
+
+			activeCommitIdx.$ = n;
+
+			if (activeCommit.$ && !activeCommit.$.nodes.has(selectedNodeId.$)) {
+				selectedNodeId.$ = activeCommit.$.rootId;
+			}
 		},
 		[store],
 	);
diff --git a/src/view/components/profiler/data/commits.ts b/src/view/components/profiler/data/commits.ts
index 39138c6..f54f366 100644
--- a/src/view/components/profiler/data/commits.ts
+++ b/src/view/components/profiler/data/commits.ts
@@ -6,6 +6,7 @@ import {
 	RenderReasonMap,
 	RenderReasonData,
 } from "../../../../adapter/10/renderer/renderReasons";
+import { flattenNodeTree } from "../flamegraph/placeNodes";
 
 export interface ProfilerNode extends DevNode {
 	selfDuration: number;
@@ -164,7 +165,7 @@ export function resetProfiler(state: ProfilerState) {
 export function recordProfilerCommit(
 	tree: Map<ID, DevNode>,
 	profiler: ProfilerState,
-	rootId: number,
+	commitRootId: number,
 ) {
 	const nodes = new Map<ID, ProfilerNode>();
 
@@ -174,8 +175,13 @@ export function recordProfilerCommit(
 	// The time of the node that took the longest to render
 	let maxSelfDuration = 0;
 
+	const rootId = getRoot(tree, commitRootId);
+	const items = new Set(flattenNodeTree(tree, rootId).map(x => x.id));
+
 	// Make shallow copies of each node
 	tree.forEach((node, id) => {
+		if (!items.has(id)) return;
+
 		nodes.set(id, {
 			// deep clone
 			...JSON.parse(JSON.stringify(node)),
@@ -216,15 +222,15 @@ export function recordProfilerCommit(
 
 	// Total commit duration
 	let duration = 0;
-	const root = nodes.get(rootId);
+	const root = nodes.get(commitRootId);
 	if (root) {
 		duration = root.treeEndTime - root.treeStartTime;
 	}
 
 	profiler.commits.update(arr => {
 		arr.push({
-			rootId: getRoot(tree, rootId),
-			commitRootId: rootId,
+			rootId: getRoot(tree, commitRootId),
+			commitRootId: commitRootId,
 			nodes,
 			maxDepth,
 			maxSelfDuration,
diff --git a/src/view/components/profiler/data/gradient.ts b/src/view/components/profiler/data/gradient.ts
index b25a5af..cfa95bf 100644
--- a/src/view/components/profiler/data/gradient.ts
+++ b/src/view/components/profiler/data/gradient.ts
@@ -10,5 +10,5 @@ export function getGradient(max: number, n: number) {
 		}
 	}
 
-	return i;
+	return Math.max(i, 0);
 }
diff --git a/src/view/components/profiler/flamegraph/FlameGraph.css b/src/view/components/profiler/flamegraph/FlameGraph.css
index 60bf21c..52fbd76 100644
--- a/src/view/components/profiler/flamegraph/FlameGraph.css
+++ b/src/view/components/profiler/flamegraph/FlameGraph.css
@@ -2,8 +2,8 @@
 	width: 100%;
 	max-width: 100%;
 	height: 100%;
-	overflow: hidden;
 	position: relative;
+	overflow-x: hidden;
 }
 
 .node {
@@ -11,14 +11,20 @@
 	cursor: pointer;
 	color: black;
 	font-size: 0.8rem;
-	padding: 0 0.2rem;
+	padding: 0;
 	height: 100%;
-	white-space: nowrap;
-	overflow: hidden;
-	text-overflow: ellipsis;
 	transition-property: all;
 	transition-duration: 0.3s;
 	padding-bottom: 1px;
+	display: flex;
+	align-items: center;
+}
+
+.text {
+	padding: 0 0.2rem;
+	white-space: nowrap;
+	text-overflow: ellipsis;
+	overflow: hidden;
 }
 
 .node::after {
@@ -36,17 +42,30 @@
 	display: none;
 }
 
-.node[data-weight="-1"]:not([data-overflow]) {
+.node[data-visible] {
+	transition-property: all;
+}
+.node:not([data-visible]) {
+	transition-property: opacity;
+	transition-duration: 0.2s;
+	opacity: 0;
+	pointer-events: none;
+}
+.node[data-visible]:not([data-maximized]) {
+	opacity: 1;
+}
+
+.node[data-weight="-2"]:not([data-overflow]) {
 	color: var(--color-bystander-text);
 }
-.node[data-weight="-1"]::after {
+.node[data-weight="-2"]::after {
 	/* inline-pattern */
 	background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0IDQiPgo8ZGVmcz4KICA8cGF0dGVybgogICAgaWQ9ImRpYWdvbmFsLWhhdGNoIgogICAgcGF0dGVyblVuaXRzPSJ1c2VyU3BhY2VPblVzZSIKICAgIHdpZHRoPSI0IgogICAgaGVpZ2h0PSI0IgogID4KICAgIDxwYXRoCiAgICAgIGQ9Ik0tMSwxIGwyLC0yCk0wLDQgbDQsLTQKTTMsNSBsMiwtMiIKc3Ryb2tlPSIjZGVkZWRlIgpzdHJva2Utd2lkdGg9IjEiCiAgICAvPgogIDwvcGF0dGVybj4KICA8L2RlZnM+CiAgPHJlY3QKICAgIHg9IjAiCiAgICB5PSIwIgogICAgd2lkdGg9IjQiCiAgICBoZWlnaHQ9IjQiCiAgICBmaWxsPSJ1cmwoI2RpYWdvbmFsLWhhdGNoKSIKICA+PC9yZWN0Pgo8L3N2Zz4=");
 	background-repeat: repeat;
 	background-color: transparent;
 	background-size: 0.25rem 0.25rem;
 }
-:global(.dark) .node[data-weight="-1"]::after {
+:global(.dark) .node[data-weight="-2"]::after {
 	opacity: 0.5;
 }
 
@@ -54,6 +73,9 @@
 	opacity: 0.5;
 }
 
+.node[data-weight="-1"]::after {
+	background: var(--color-profiler-old);
+}
 .node[data-weight="0"]::after {
 	background: var(--color-profiler-gradient-0);
 }
diff --git a/src/view/components/profiler/flamegraph/FlameGraph.tsx b/src/view/components/profiler/flamegraph/FlameGraph.tsx
index d897efc..341567e 100644
--- a/src/view/components/profiler/flamegraph/FlameGraph.tsx
+++ b/src/view/components/profiler/flamegraph/FlameGraph.tsx
@@ -1,85 +1,49 @@
 import { h } from "preact";
 import s from "./FlameGraph.css";
-import { useStore, useObserver } from "../../../store/react-bindings";
 import {
-	useRef,
-	useState,
-	useCallback,
-	useEffect,
-	useMemo,
-} from "preact/hooks";
+	useStore,
+	useObserver,
+	WindowCtx,
+} from "../../../store/react-bindings";
+import { useRef, useCallback, useContext } from "preact/hooks";
 import { formatTime } from "../util";
-import { CommitData, FlamegraphType } from "../data/commits";
+import { FlamegraphType, ProfilerNode } from "../data/commits";
 import { createFlameGraphStore } from "./FlamegraphStore";
 import { useInstance, useResize } from "../../utils";
+import { FlameNode } from "./FlameNode";
 import { ID } from "../../../store/types";
 
-const ROW_HEIGHT = 21; // Account 1px for border
-
-const EMTPY: CommitData = {
-	rootId: -1,
-	commitRootId: -1,
-	maxDepth: 0,
-	maxSelfDuration: 0,
-	duration: 0,
-	nodes: new Map(),
-};
-
-export interface TransformData {
-	transform: string;
-	opacity: number;
-	width: number;
-}
-
 export function FlameGraph() {
 	const store = useStore();
-	const commit = useObserver(() => {
-		return store.profiler.activeCommit.$ || EMTPY;
-	});
 
-	const selected = useObserver(() => store.profiler.activeCommitIdx.$);
+	const displayType = useObserver(() => store.profiler.flamegraphType.$);
+	const selectedId = useObserver(() => store.profiler.selectedNodeId.$);
+	const commit = useObserver(() => store.profiler.activeCommit.$);
+	const nodes = commit ? commit.nodes : new Map<ID, ProfilerNode>();
 
 	const ref2 = useInstance(() => createFlameGraphStore(store.profiler));
-	const nodes = useObserver(() => ref2.nodes.$);
-	const colorMap = useObserver(() => ref2.colors.$);
-	const activeParents = useObserver(() => ref2.activeParents.$);
-	const selectedNodeId = useObserver(() => store.profiler.selectedNodeId.$);
-	const selectedIndex = useMemo(
-		() => nodes.findIndex(x => x.id === selectedNodeId),
-		[selectedNodeId, nodes],
-	);
-
-	const cache = useRef(new Map<ID, TransformData>());
-	const oldPosition = useMemo(() => {
-		const cacheNew = new Map<ID, TransformData>();
-		nodes.forEach(node => {
-			if (cache.current.has(node.id)) {
-				cacheNew.set(node.id, cache.current.get(node.id)!);
-			} else {
-				cacheNew.set(node.id, { transform: "", opacity: 0, width: 0 });
-			}
-		});
-		cache.current = cacheNew;
-		return cacheNew;
-	}, [commit.commitRootId]);
+	const positionData = useObserver(() => {
+		return !store.profiler.isRecording.$ ? ref2.nodes.$ : [];
+	});
 
-	const displayType = useObserver(() => store.profiler.flamegraphType.$);
-	const [canvasWidth, setWidth] = useState(100);
+	const win = useContext(WindowCtx) || window;
+	if (process.env.DEBUG) {
+		(win as any).flamegraph = ref2;
+	}
 
 	const ref = useRef<HTMLDivElement>();
-	useEffect(() => {
-		if (ref.current) {
-			setWidth(ref.current.clientWidth);
-		}
-	}, [ref.current, selectedNodeId, selected, displayType]);
-
-	useResize(() => {
-		if (ref.current) {
-			setWidth(ref.current.clientWidth);
-		}
-	}, []);
-
-	const isRecording = useObserver(() => store.profiler.isRecording.$);
+	useResize(
+		() => {
+			if (ref.current) {
+				const width = ref.current.clientWidth;
+				if (ref2.canvasWidth.$ !== width) {
+					ref2.canvasWidth.$ = width;
+				}
+			}
+		},
+		[positionData.length],
+		true,
+	);
 
 	const onSelect = useCallback(
 		(id: number) => {
@@ -89,63 +53,24 @@ export function FlameGraph() {
 		[store],
 	);
 
-	if (isRecording || !nodes.length) {
-		return null;
-	}
-
-	const scale = (canvasWidth || 1) / nodes[0].width;
-
 	return (
 		<div class={s.root} ref={ref} data-type={displayType.toLowerCase()}>
-			{nodes.map((meta, i) => {
-				const x = meta.x * scale;
-				const y = meta.row * ROW_HEIGHT;
-				const width = meta.width * scale;
-
-				const node = commit.nodes.get(meta.id)!;
-
-				const pos = oldPosition.get(meta.id)!;
-				let property = "opacity";
-				if (
-					activeParents.has(meta.id) ||
-					(x >= 0 && x <= canvasWidth) ||
-					(x + width >= 0 && x + width <= canvasWidth)
-				) {
-					if (pos.opacity > 0) property = "all";
-					pos.transform = `translate3d(${x}px,${y}px,0)`;
-					pos.opacity = 1;
-					pos.width = Math.max(2, width); // 2 for HiDPI screens
-				} else {
-					pos.opacity = 0;
-				}
-
-				const color = colorMap.get(meta.id);
+			{positionData.map(pos => {
+				const node = nodes.get(pos.id)!;
 				return (
-					<div
-						key={meta.id}
-						class={s.node}
-						onClick={() => onSelect(meta.id)}
-						data-weight={
-							color == null && !activeParents.has(meta.id) ? -1 : color
-						}
-						data-maximized={i <= selectedIndex}
-						data-selected={selectedNodeId === meta.id}
-						data-overflow={width <= 32}
-						style={{
-							width: pos.width,
-							height: ROW_HEIGHT,
-							opacity: pos.opacity,
-							transform: pos.transform,
-							transitionProperty: property,
-						}}
+					<FlameNode
+						key={pos.id}
+						parentId={node.parent}
+						node={pos}
+						commitRootId={commit ? commit.commitRootId : -1}
+						onClick={() => onSelect(pos.id)}
+						selected={selectedId === pos.id}
 					>
-						<span class={s.text}>
-							{node.name} ({formatTime(node.selfDuration)}
-							{displayType === FlamegraphType.FLAMEGRAPH &&
-								"of " + formatTime(node.treeEndTime - node.treeStartTime)}
-							)
-						</span>
-					</div>
+						{node.name} ({formatTime(node.selfDuration)}
+						{displayType === FlamegraphType.FLAMEGRAPH &&
+							" of " + formatTime(node.treeEndTime - node.treeStartTime)}
+						)
+					</FlameNode>
 				);
 			})}
 		</div>
diff --git a/src/view/components/profiler/flamegraph/FlameNode.tsx b/src/view/components/profiler/flamegraph/FlameNode.tsx
new file mode 100644
index 0000000..f6bd7a9
--- /dev/null
+++ b/src/view/components/profiler/flamegraph/FlameNode.tsx
@@ -0,0 +1,48 @@
+import { h } from "preact";
+import { useRef } from "preact/hooks";
+import s from "./FlameGraph.css";
+import { NodeTransform } from "./transform/focusNode";
+
+export interface Props {
+	selected: boolean;
+	children: any;
+	parentId: number;
+	commitRootId: number;
+	onClick: () => void;
+	node: NodeTransform;
+}
+
+const ROW_HEIGHT = 21; // Account 1px for border
+const MIN_TEXT_WIDTH = 32; // Don't show text if smaller than this value
+
+export function FlameNode(props: Props) {
+	const { onClick, selected, node } = props;
+	const transform = useRef("");
+	const widthCss = useRef("");
+
+	const { visible, width, x } = node;
+
+	if (visible) {
+		const y = node.row * ROW_HEIGHT;
+		widthCss.current = `width: ${Math.max(width, 2)}px;`;
+		transform.current = `transform: translate3d(${x}px, ${y}px, 0);`;
+	}
+
+	return (
+		<div
+			class={s.node}
+			onClick={onClick}
+			data-id={node.id}
+			data-commit-root={props.commitRootId}
+			data-parent-id={props.parentId}
+			data-visible={visible}
+			data-weight={node.weight}
+			data-maximized={node.maximized}
+			data-selected={selected}
+			data-overflow={width < MIN_TEXT_WIDTH}
+			style={`height: ${ROW_HEIGHT}px; ${transform.current} ${widthCss.current};`}
+		>
+			<span class={s.text}>{props.children}</span>
+		</div>
+	);
+}
diff --git a/src/view/components/profiler/flamegraph/FlamegraphStore.ts b/src/view/components/profiler/flamegraph/FlamegraphStore.ts
index 1633e8a..2f4d18f 100644
--- a/src/view/components/profiler/flamegraph/FlamegraphStore.ts
+++ b/src/view/components/profiler/flamegraph/FlamegraphStore.ts
@@ -1,137 +1,19 @@
-import { watch } from "../../../valoo";
-import { ProfilerState, FlamegraphType, ProfilerNode } from "../data/commits";
-import { getGradient } from "../data/gradient";
+import { valoo } from "../../../valoo";
+import { ProfilerState, ProfilerNode } from "../data/commits";
 import { ID, Tree } from "../../../store/types";
-import { layoutRanked } from "./modes/ranked";
-import { layoutTimeline } from "./modes/flamegraph";
-import { focusNode, NodeTransform } from "./transform/focusNode";
-import { padNodes } from "./transform/pad";
-
-/**
- * The minimum width of a node inside the flamegraph.
- * This ensures that the user doesn't miss any nodes
- * that would be smaller than <1px because of zooming
- */
-export const MIN_WIDTH = 6;
-
-/**
- * Height of each row of the flamegraph. All items have
- * a fixed height.
- */
-export const ROW_HEIGHT = 20;
-
-export function flattenNodeTree<K, T extends { id: K; children: K[] }>(
-	tree: Map<K, T>,
-	id: K,
-): T[] {
-	const out: T[] = [];
-	const visited = new Set<K>();
-	const stack: K[] = [id];
-
-	while (stack.length > 0) {
-		const item = stack.pop();
-		if (item == null) continue;
-
-		const node = tree.get(item);
-		if (!node) continue;
-
-		if (!visited.has(node.id)) {
-			out.push(node);
-			visited.add(node.id);
-
-			for (let i = node.children.length; i--; ) {
-				stack.push(node.children[i]);
-			}
-		}
-	}
-
-	return out;
-}
+import { placeNodes } from "./placeNodes";
 
 export function createFlameGraphStore(profiler: ProfilerState) {
-	const layoutNodes = watch(() => {
-		const commit = profiler.activeCommit.$;
-		const current = profiler.selectedNode.$;
-
-		if (commit === null) {
-			return [];
-		}
-
-		const items = flattenNodeTree(commit.nodes, commit.rootId);
-
-		const viewMode = profiler.flamegraphType.$;
-		let nodes: NodeTransform[] = [];
-		if (viewMode === FlamegraphType.RANKED) {
-			const root = commit.nodes.get(commit.commitRootId)!;
-			nodes = layoutRanked(
-				items.filter(node => {
-					return (
-						node.startTime >= root.startTime && node.endTime <= root.endTime
-					);
-				}),
-			);
-		} else if (viewMode === FlamegraphType.FLAMEGRAPH) {
-			nodes = layoutTimeline(items);
-
-			// Normalize timeline if it has an offset
-			const offset = nodes.length > 0 ? nodes[0].x : 0;
-			nodes = nodes.map(node => {
-				return { ...node, x: node.x - offset };
-			});
-		}
-
-		nodes = padNodes(nodes, 0.1);
-
-		if (nodes.length > 0) {
-			nodes = focusNode(nodes, current === null ? nodes[0].id : current.id);
-		}
-
-		return nodes;
-	});
-
-	const colors = watch(() => {
-		const state = new Map<ID, number>();
-		const commit = profiler.activeCommit.$;
-		if (commit !== null) {
-			const root = commit.commitRootId;
-
-			const children = flattenNodeTree(commit.nodes, root);
-			children.forEach(child => {
-				state.set(
-					child.id,
-					getGradient(commit.maxSelfDuration, child.selfDuration || 1),
-				);
-			});
-		}
-
-		return state;
-	});
+	const canvasWidth = valoo(600);
 
-	const activeParents = watch(() => {
-		const parents = new Set<ID>();
-		const commit = profiler.activeCommit.$;
-		if (commit !== null) {
-			// Collect all parents from the node where the update
-			// originated from.
-			let current = commit.commitRootId;
-			while (current > -1) {
-				const node = commit.nodes.get(current);
-				if (node && node.parent > -1) {
-					parents.add(node.parent);
-					current = node.parent;
-				} else {
-					break;
-				}
-			}
-		}
-		return parents;
-	});
+	const nodes = placeNodes(
+		profiler.activeCommit,
+		profiler.flamegraphType,
+		profiler.selectedNodeId,
+		canvasWidth,
+	);
 
-	return {
-		activeParents,
-		nodes: layoutNodes,
-		colors,
-	};
+	return { nodes, canvasWidth };
 }
 
 export function getRoot(tree: Tree, id: ID) {
diff --git a/src/view/components/profiler/flamegraph/modes/flamegraph.ts b/src/view/components/profiler/flamegraph/modes/flamegraph.ts
index 7e3e3bd..96f7166 100644
--- a/src/view/components/profiler/flamegraph/modes/flamegraph.ts
+++ b/src/view/components/profiler/flamegraph/modes/flamegraph.ts
@@ -1,18 +1,42 @@
 import { ProfilerNode } from "../../data/commits";
 import { NodeTransform } from "../transform/focusNode";
+import { getGradient } from "../../data/gradient";
 
 /**
  * The default layout that's typical for a flamegraph chart.
  */
-export function layoutTimeline(nodes: ProfilerNode[]): NodeTransform[] {
+export function layoutTimeline(
+	nodes: ProfilerNode[],
+	root: ProfilerNode,
+	selected: ProfilerNode,
+	maxSelfDuration: number,
+): NodeTransform[] {
+	const offset = selected.treeStartTime;
+
 	return nodes
 		.sort((a, b) => a.treeStartTime - b.treeStartTime)
 		.map(node => {
+			let weight = -1;
+			if (
+				node.treeEndTime <= root.treeStartTime ||
+				node.treeStartTime > root.treeEndTime
+			) {
+				weight = -2;
+			} else if (root.depth > node.depth) {
+				weight = -5;
+			} else {
+				weight = getGradient(maxSelfDuration, node.selfDuration);
+			}
+
 			return {
 				id: node.id,
-				x: node.treeStartTime,
+				// Normalize x coordinate
+				x: node.treeStartTime - offset,
 				row: node.depth,
 				width: node.treeEndTime - node.treeStartTime,
+				weight,
+				maximized: false,
+				visible: true,
 			};
 		});
 }
diff --git a/src/view/components/profiler/flamegraph/modes/ranked.ts b/src/view/components/profiler/flamegraph/modes/ranked.ts
index 7ff532e..543e8f5 100644
--- a/src/view/components/profiler/flamegraph/modes/ranked.ts
+++ b/src/view/components/profiler/flamegraph/modes/ranked.ts
@@ -1,13 +1,21 @@
 import { NodeTransform } from "../transform/focusNode";
 import { ProfilerNode } from "../../data/commits";
+import { getGradient } from "../../data/gradient";
 
 /**
  * Layout nodes in a flat list from top to bottom
  * in descending order by the time it took a nod
  * to render.
  */
-export function layoutRanked(nodes: ProfilerNode[]): NodeTransform[] {
+export function layoutRanked(
+	nodes: ProfilerNode[],
+	root: ProfilerNode,
+	maxSelfDuration: number,
+): NodeTransform[] {
 	return nodes
+		.filter(
+			node => node.startTime >= root.startTime && node.endTime <= root.endTime,
+		)
 		.sort((a, b) => b.selfDuration - a.selfDuration)
 		.map((node, i) => {
 			return {
@@ -16,6 +24,9 @@ export function layoutRanked(nodes: ProfilerNode[]): NodeTransform[] {
 				width: Math.max(node.selfDuration, 0.01),
 				x: 0,
 				row: i,
+				maximized: false,
+				weight: getGradient(maxSelfDuration, node.selfDuration),
+				visible: true,
 			};
 		});
 }
diff --git a/src/view/components/profiler/flamegraph/placeNodes.ts b/src/view/components/profiler/flamegraph/placeNodes.ts
new file mode 100644
index 0000000..c73f0c9
--- /dev/null
+++ b/src/view/components/profiler/flamegraph/placeNodes.ts
@@ -0,0 +1,124 @@
+import { ID } from "../../../store/types";
+import { CommitData, FlamegraphType, ProfilerNode } from "../data/commits";
+import { watch, Observable } from "../../../valoo";
+import { layoutTimeline } from "./modes/flamegraph";
+import { layoutRanked } from "./modes/ranked";
+import { focusNode } from "./transform/focusNode";
+import { padNodes } from "./transform/pad";
+
+/**
+ * Flatten profiler node tree into a flat list
+ */
+export function flattenNodeTree<K, T extends { id: K; children: K[] }>(
+	tree: Map<K, T>,
+	id: K,
+): T[] {
+	const out: T[] = [];
+	const visited = new Set<K>();
+	const stack: K[] = [id];
+
+	while (stack.length > 0) {
+		const item = stack.pop();
+		if (item == null) continue;
+
+		const node = tree.get(item);
+		if (!node) continue;
+
+		if (!visited.has(node.id)) {
+			out.push(node);
+			visited.add(node.id);
+
+			for (let i = node.children.length; i--; ) {
+				stack.push(node.children[i]);
+			}
+		}
+	}
+
+	return out;
+}
+
+const EMPTY: ProfilerNode = {
+	children: [],
+	depth: 0,
+	duration: 0,
+	endTime: 0,
+	id: -1,
+	key: "",
+	name: "",
+	parent: -1,
+	selfDuration: 0,
+	startTime: 0,
+	treeEndTime: 0,
+	treeStartTime: 0,
+	type: 0,
+};
+
+/**
+ * The minimum width of a node inside the flamegraph.
+ * This ensures that the user doesn't miss any nodes
+ * that would be smaller than <1px because of zooming
+ */
+export const MIN_WIDTH = 6;
+
+/**
+ * This function is responsible for creating the diagram layout
+ * for the profiler. It's divided into steps so that we can reuse
+ * as much as possible from the previous calculations on each user
+ * action.
+ * @param commit Commit data
+ * @param viewMode The diagram mode "flamegraph" | "ranked"
+ * @param selectedId The currently selected id
+ * @param canvasWidth The width of the canvas
+ */
+export function placeNodes(
+	commit: Observable<CommitData | null>,
+	viewMode: Observable<FlamegraphType>,
+	selectedId: Observable<ID>,
+	canvasWidth: Observable<number>,
+) {
+	const sorted = watch(() => {
+		if (!commit.$) return [];
+
+		// 1. Preparation
+		const nodes = flattenNodeTree(commit.$.nodes, commit.$.rootId);
+		const root = commit.$.nodes.get(commit.$.commitRootId) || EMPTY;
+		const maxDuration = commit.$.maxSelfDuration;
+
+		const selected = commit.$.nodes.get(selectedId.$) || root;
+
+		// 2. Sorting (view mode) + coloring
+		const prepared =
+			viewMode.$ === FlamegraphType.FLAMEGRAPH
+				? layoutTimeline(nodes, root, selected, maxDuration)
+				: layoutRanked(nodes, root, maxDuration);
+
+		return padNodes(prepared, 0.1);
+	});
+
+	// 3. Focus
+	const focused = watch(() => {
+		return focusNode(sorted.$, selectedId.$);
+	});
+
+	// 4. Resize to available canvas space
+	const resized = watch(() => {
+		if (!focused.$.length) return [];
+
+		const scale = (canvasWidth.$ || 1) / focused.$[0].width;
+
+		const out = focused.$.map(node => {
+			const x = node.x * scale;
+			const width = node.width * scale;
+			const visible =
+				node.maximized ||
+				(x >= 0 && x <= canvasWidth.$) ||
+				(x + width >= 0 && x + width <= canvasWidth.$);
+			return { ...node, x, width, visible };
+		});
+
+		// 5. Pad min-width
+		return padNodes(out, MIN_WIDTH);
+	});
+
+	return resized;
+}
diff --git a/src/view/components/profiler/flamegraph/transform/focusNode.ts b/src/view/components/profiler/flamegraph/transform/focusNode.ts
index dd51a35..0d840af 100644
--- a/src/view/components/profiler/flamegraph/transform/focusNode.ts
+++ b/src/view/components/profiler/flamegraph/transform/focusNode.ts
@@ -1,10 +1,14 @@
 import { ID } from "../../../../store/types";
+import { deepClone } from "./util";
 
 export interface NodeTransform {
 	id: ID;
 	x: number;
 	row: number;
 	width: number;
+	weight: number;
+	maximized: boolean;
+	visible: boolean;
 }
 
 /**
@@ -12,7 +16,7 @@ export interface NodeTransform {
  * will be maximized too.
  */
 export function focusNode(nodes: NodeTransform[], id: ID) {
-	nodes = nodes.slice();
+	nodes = deepClone(nodes);
 
 	// Populate caches
 	const byId = new Map<ID, NodeTransform>();
@@ -41,8 +45,15 @@ export function focusNode(nodes: NodeTransform[], id: ID) {
 			rows[node.row] = [];
 		}
 
-		if (target.x >= node.x && target.x + target.width <= node.x + node.width) {
+		if (
+			target.row >= node.row &&
+			target.x >= node.x &&
+			target.x + target.width <= node.x + node.width
+		) {
+			node.maximized = true;
 			parents.push(node);
+		} else {
+			node.maximized = false;
 		}
 
 		rows[node.row].push(node);
diff --git a/src/view/components/profiler/flamegraph/transform/pad.ts b/src/view/components/profiler/flamegraph/transform/pad.ts
index da95fa3..030e752 100644
--- a/src/view/components/profiler/flamegraph/transform/pad.ts
+++ b/src/view/components/profiler/flamegraph/transform/pad.ts
@@ -24,7 +24,12 @@ export function padNodes(
 
 		nodes.forEach(node => {
 			// Enlarge width of parents
-			if (node.x <= pad.x && node.x + node.width >= pad.x + pad.width) {
+			if (
+				node.x <= pad.x &&
+				(node.x + node.width >= pad.x + pad.width ||
+					(node.x + node.width > pad.x &&
+						node.x + node.width < pad.x + pad.width))
+			) {
 				if (node.id !== pad.id) {
 					node.width += factor;
 				}
diff --git a/src/view/components/profiler/flamegraph/transform/patchTree.ts b/src/view/components/profiler/flamegraph/transform/patchTree.ts
index 8f52eba..650d239 100644
--- a/src/view/components/profiler/flamegraph/transform/patchTree.ts
+++ b/src/view/components/profiler/flamegraph/transform/patchTree.ts
@@ -26,9 +26,9 @@ export function patchTree(old: Tree, next: Tree, rootId: ID): Tree {
 
 	const deltaStart = oldRoot.treeStartTime - root.startTime;
 	const deltaEnd =
-		oldRoot.treeStartTime +
-		(root.endTime - root.startTime) -
-		oldRoot.treeEndTime;
+		oldRoot.treeEndTime -
+		oldRoot.treeStartTime -
+		(root.endTime - root.startTime);
 
 	// Move new tree to old tree position.
 	out.set(root.id, {
@@ -48,7 +48,7 @@ export function patchTree(old: Tree, next: Tree, rootId: ID): Tree {
 	});
 
 	// Enlarge parents and move children
-	adjustNodesToRight(out, root.id, deltaEnd);
+	adjustNodesToRight(out, root.id, -deltaEnd);
 
 	return out;
 }
diff --git a/src/view/components/profiler/flamegraph/transform/util.ts b/src/view/components/profiler/flamegraph/transform/util.ts
index 778eb8f..07e772c 100644
--- a/src/view/components/profiler/flamegraph/transform/util.ts
+++ b/src/view/components/profiler/flamegraph/transform/util.ts
@@ -5,7 +5,7 @@ export function mapParents(
 	id: ID,
 	fn: (parent: DevNode, prevParent: DevNode) => void | boolean,
 ) {
-	const prevParent = tree.get(id)!;
+	let prevParent = tree.get(id)!;
 	if (!prevParent) return;
 
 	let item = tree.get(prevParent.parent);
@@ -19,6 +19,7 @@ export function mapParents(
 			break;
 		}
 
+		prevParent = item;
 		item = tree.get(item.parent);
 	}
 }
@@ -48,16 +49,14 @@ export function adjustNodesToRight(
 		parent.treeEndTime += delta;
 
 		const idx = parent.children.indexOf(prevParent.id);
-		if (idx > -1) {
-			const tasks = parent.children.slice(idx + 1);
+		const tasks = idx > -1 ? parent.children.slice(idx + 1) : parent.children;
 
-			tasks.forEach(childId => {
-				mapChildren(tree, childId, child => {
-					child.treeEndTime += delta;
-					child.treeStartTime += delta;
-				});
+		tasks.forEach(childId => {
+			mapChildren(tree, childId, child => {
+				child.treeEndTime += delta;
+				child.treeStartTime += delta;
 			});
-		}
+		});
 	});
 }
 
diff --git a/src/view/components/utils.ts b/src/view/components/utils.ts
index e4ec4ff..57d8061 100644
--- a/src/view/components/utils.ts
+++ b/src/view/components/utils.ts
@@ -72,7 +72,7 @@ export function useInstance<T>(fn: () => T) {
 	return value;
 }
 
-export function useResize(fn: () => void, args: any[]) {
+export function useResize(fn: () => void, args: any[], init = false) {
 	// If we're running inside the browser extension context
 	// we pull the correct window reference from context. And
 	// yes there are multiple `window` objects to keep track of.
@@ -80,8 +80,13 @@ export function useResize(fn: () => void, args: any[]) {
 	// triggered. For testing scenarios we can fall back to
 	// the global window object instead.
 	const win = useContext(WindowCtx) || window;
-	const fn2 = throttle(fn, 100);
+
+	useEffect(() => {
+		if (init) fn();
+	}, []);
+
 	useLayoutEffect(() => {
+		const fn2 = throttle(fn, 60);
 		win.addEventListener("resize", fn2);
 		return () => {
 			win.removeEventListener("resize", fn2);
diff --git a/yarn.lock b/yarn.lock
index 3d8021f..2afeba0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -383,6 +383,13 @@
     "@nodelib/fs.scandir" "2.1.1"
     fastq "^1.6.0"
 
+"@rollup/plugin-alias@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-alias/-/plugin-alias-3.1.0.tgz#4f7bc9d15e030d75da9224aaa5105129c54a3ffd"
+  integrity sha512-IzoejtAqdfwAvx4D0bztAJFoL5Js36kJgnbO00zfI1B9jf9G80vWysyG0C4+E6w5uG5hz0EeetPpoBWKdNktCQ==
+  dependencies:
+    slash "^3.0.0"
+
 "@rollup/plugin-replace@^2.3.2":
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-2.3.2.tgz#da4e0939047f793c2eb5eedfd6c271232d0a033f"


Test patch:
diff --git a/src/view/components/profiler/flamegraph/FlamegraphStore.test.ts b/src/view/components/profiler/flamegraph/FlamegraphStore.test.ts
index 0ffceba..fad248e 100644
--- a/src/view/components/profiler/flamegraph/FlamegraphStore.test.ts
+++ b/src/view/components/profiler/flamegraph/FlamegraphStore.test.ts
@@ -63,7 +63,7 @@ describe("FlameGraphStore", () => {
 						Foo **   Bar ***
 				`;
 			profiler.commits.$ = [tree.commit];
-			expect(flame.nodes.$.map(x => x.width)).to.deep.equal([80, 70, 60]);
+			expect(flame.nodes.$.map(x => x.width)).to.deep.equal([600, 525, 450]);
 		});
 
 		it("should rank nodes by selfDuration #2", () => {
@@ -86,7 +86,7 @@ describe("FlameGraphStore", () => {
 			`;
 			profiler.commits.$ = [tree.commit];
 
-			expect(flame.nodes.$.map(x => x.width)).to.deep.equal([100, 80, 60]);
+			expect(flame.nodes.$.map(x => x.width)).to.deep.equal([600, 480, 360]);
 		});
 
 		it("should support maximizing nodes", () => {
@@ -104,9 +104,9 @@ describe("FlameGraphStore", () => {
 			]);
 
 			expect(flame.nodes.$.map(x => x.width)).to.deep.equal([
-				100,
-				100,
-				83.33333333333334,
+				600,
+				600,
+				500.00000000000006,
 			]);
 		});
 	});
@@ -138,21 +138,30 @@ describe("FlameGraphStore", () => {
 					name: "App",
 					x: 0,
 					row: 0,
-					width: 120,
+					width: 600,
+					visible: true,
+					maximized: true,
+					weight: 7,
 				},
 				{
 					id: 2,
 					name: "Foo",
-					x: 20,
+					x: 100,
 					row: 1,
-					width: 80,
+					width: 400,
+					visible: true,
+					maximized: false,
+					weight: 5,
 				},
 				{
 					id: 3,
 					name: "Bar",
-					x: 40,
+					x: 200,
 					row: 2,
-					width: 50,
+					width: 250,
+					visible: true,
+					maximized: false,
+					weight: 9,
 				},
 			]);
 		});
@@ -179,21 +188,30 @@ describe("FlameGraphStore", () => {
 					name: "App",
 					x: 0,
 					row: 0,
-					width: 120,
+					width: 600,
+					visible: true,
+					maximized: true,
+					weight: 7,
 				},
 				{
 					id: 2,
 					name: "Foo",
 					x: 0,
 					row: 1,
-					width: 120,
+					width: 600,
+					visible: true,
+					maximized: true,
+					weight: 5,
 				},
 				{
 					id: 3,
 					name: "Bar",
-					x: 30,
+					x: 150,
 					row: 2,
-					width: 75,
+					width: 375,
+					visible: true,
+					maximized: false,
+					weight: 9,
 				},
 			]);
 		});
@@ -220,35 +238,50 @@ describe("FlameGraphStore", () => {
 					name: "App",
 					x: 0,
 					row: 0,
-					width: 310,
+					width: 600,
+					maximized: true,
+					weight: 9,
+					visible: true,
 				},
 				{
 					id: 2,
 					name: "Foo",
-					x: -516.6666666666667,
+					x: -1000.0000000000002,
 					row: 1,
-					width: 413.33333333333337,
+					width: 800.0000000000001,
+					visible: false,
+					maximized: false,
+					weight: 3,
 				},
 				{
 					id: 4,
 					name: "Bob",
 					x: 0,
 					row: 1,
-					width: 310,
+					width: 600,
+					weight: 6,
+					visible: true,
+					maximized: true,
 				},
 				{
 					id: 5,
 					name: "Boof",
-					x: 465,
+					x: 900,
 					row: 1,
-					width: 413.33333333333337,
+					width: 800.0000000000001,
+					weight: 8,
+					visible: false,
+					maximized: false,
 				},
 				{
 					id: 3,
 					name: "Bar",
-					x: -413.33333333333337,
+					x: -800.0000000000001,
 					row: 2,
-					width: 258.33333333333337,
+					width: 500.0000000000001,
+					visible: false,
+					maximized: false,
+					weight: 5,
 				},
 			]);
 		});
diff --git a/src/view/components/profiler/flamegraph/modes/flamegraph.test.ts b/src/view/components/profiler/flamegraph/modes/flamegraph.test.ts
index 2f1fb0e..a41027c 100644
--- a/src/view/components/profiler/flamegraph/modes/flamegraph.test.ts
+++ b/src/view/components/profiler/flamegraph/modes/flamegraph.test.ts
@@ -2,34 +2,41 @@ import { expect } from "chai";
 import { flames } from "../testHelpers";
 import { layoutTimeline } from "./flamegraph";
 
-describe("FlameGraph", () => {
-	describe("layoutTimeline", () => {
-		it("should display simple flamegraph", () => {
-			const tree = flames`
+const required = {
+	visible: true,
+	maximized: false,
+};
+
+describe("layoutTimeline", () => {
+	it("should display simple flamegraph", () => {
+		const tree = flames`
         App **********
           Foo ****
             Bar *
       `;
-			expect(layoutTimeline(tree.nodes)).to.deep.equal([
-				{ id: 1, width: 140, x: 0, row: 0 },
-				{ id: 2, width: 80, x: 20, row: 1 },
-				{ id: 3, width: 50, x: 40, row: 2 },
-			]);
-		});
+		expect(
+			layoutTimeline(tree.nodes, tree.root, tree.root, tree.root.selfDuration),
+		).to.deep.equal([
+			{ ...required, id: 1, width: 140, x: 0, row: 0, weight: 9 },
+			{ ...required, id: 2, width: 80, x: 20, row: 1, weight: 5 },
+			{ ...required, id: 3, width: 50, x: 40, row: 2, weight: 8 },
+		]);
+	});
 
-		it("should display simple complex flamegraph", () => {
-			const tree = flames`
+	it("should display simple complex flamegraph", () => {
+		const tree = flames`
         App *********************
           Foo ****   Baz ******
             Bar *      Bob **
       `;
-			expect(layoutTimeline(tree.nodes)).to.deep.equal([
-				{ id: 1, width: 250, x: 0, row: 0 },
-				{ id: 2, width: 80, x: 20, row: 1 },
-				{ id: 3, width: 50, x: 40, row: 2 },
-				{ id: 4, width: 100, x: 130, row: 1 },
-				{ id: 5, width: 60, x: 150, row: 2 },
-			]);
-		});
+		expect(
+			layoutTimeline(tree.nodes, tree.root, tree.root, tree.root.selfDuration),
+		).to.deep.equal([
+			{ ...required, id: 1, width: 250, x: 0, row: 0, weight: 9 },
+			{ ...required, id: 2, width: 80, x: 20, row: 1, weight: 4 },
+			{ ...required, id: 3, width: 50, x: 40, row: 2, weight: 6 },
+			{ ...required, id: 4, width: 100, x: 130, row: 1, weight: 5 },
+			{ ...required, id: 5, width: 60, x: 150, row: 2, weight: 8 },
+		]);
 	});
 });
diff --git a/src/view/components/profiler/flamegraph/modes/ranked.test.ts b/src/view/components/profiler/flamegraph/modes/ranked.test.ts
index 9bfdc94..f08ee24 100644
--- a/src/view/components/profiler/flamegraph/modes/ranked.test.ts
+++ b/src/view/components/profiler/flamegraph/modes/ranked.test.ts
@@ -1,50 +1,60 @@
 import { expect } from "chai";
 import { layoutRanked } from "./ranked";
 import { flames } from "../testHelpers";
+import { NodeTransform } from "../transform/focusNode";
 
-describe("FlameGraph", () => {
-	describe("layoutRanked", () => {
-		it("should order nodes by selfDuration", () => {
-			const tree = flames`
+const required: Partial<NodeTransform> = {
+	visible: true,
+	maximized: false,
+};
+
+describe("layoutRanked", () => {
+	it("should order nodes by selfDuration", () => {
+		const tree = flames`
         App **********
           Foo ****
             Bar *
       `;
-			expect(layoutRanked(tree.nodes)).to.deep.equal([
-				{ id: 1, width: 60, x: 0, row: 0 },
-				{ id: 3, width: 50, x: 0, row: 1 },
-				{ id: 2, width: 30, x: 0, row: 2 },
-			]);
-		});
+		expect(
+			layoutRanked(tree.nodes, tree.root, tree.root.selfDuration),
+		).to.deep.equal([
+			{ ...required, id: 1, width: 60, x: 0, row: 0, weight: 9 },
+			{ ...required, id: 3, width: 50, x: 0, row: 1, weight: 8 },
+			{ ...required, id: 2, width: 30, x: 0, row: 2, weight: 5 },
+		]);
+	});
 
-		it("should order nodes by selfDuration #2", () => {
-			const tree = flames`
+	it("should order nodes by selfDuration #2", () => {
+		const tree = flames`
         App ******************
           Foo ****  Baz **
             Bar *
       `;
-			expect(layoutRanked(tree.nodes)).to.deep.equal([
-				{ id: 1, width: 80, x: 0, row: 0 },
-				{ id: 4, width: 60, x: 0, row: 1 },
-				{ id: 3, width: 50, x: 0, row: 2 },
-				{ id: 2, width: 30, x: 0, row: 3 },
-			]);
-		});
+		expect(
+			layoutRanked(tree.nodes, tree.root, tree.root.selfDuration),
+		).to.deep.equal([
+			{ ...required, id: 1, width: 80, x: 0, row: 0, weight: 9 },
+			{ ...required, id: 4, width: 60, x: 0, row: 1, weight: 7 },
+			{ ...required, id: 3, width: 50, x: 0, row: 2, weight: 6 },
+			{ ...required, id: 2, width: 30, x: 0, row: 3, weight: 3 },
+		]);
+	});
 
-		it("should pad nodes with a width of 0", () => {
-			const tree = flames`
+	it("should pad nodes with a width of 0", () => {
+		const tree = flames`
         App ******************
           Foo ****  Baz **
             Bar *
       `;
-			tree.nodes[3].endTime = 0;
-			tree.nodes[3].selfDuration = 0;
-			expect(layoutRanked(tree.nodes)).to.deep.equal([
-				{ id: 1, width: 80, x: 0, row: 0 },
-				{ id: 3, width: 50, x: 0, row: 1 },
-				{ id: 2, width: 30, x: 0, row: 2 },
-				{ id: 4, width: 0.01, x: 0, row: 3 },
-			]);
-		});
+		tree.nodes[3].endTime = 0;
+		tree.nodes[3].selfDuration = 0;
+		expect(
+			layoutRanked(tree.nodes, tree.root, tree.root.selfDuration),
+		).to.deep.equal([
+			{ ...required, id: 1, width: 80, x: 0, row: 0, weight: 9 },
+			{ ...required, id: 3, width: 50, x: 0, row: 1, weight: 6 },
+			{ ...required, id: 2, width: 30, x: 0, row: 2, weight: 3 },
+			{ ...required, id: 4, width: 0.01, x: 0, row: 3, weight: 0 },
+		]);
 	});
 });
diff --git a/src/view/components/profiler/flamegraph/testHelpers.ts b/src/view/components/profiler/flamegraph/testHelpers.ts
index b8bf946..430f4d6 100644
--- a/src/view/components/profiler/flamegraph/testHelpers.ts
+++ b/src/view/components/profiler/flamegraph/testHelpers.ts
@@ -1,6 +1,7 @@
 import { ID, DevNodeType, Tree } from "../../../store/types";
 import { ProfilerNode, CommitData } from "../data/commits";
 import { sortTimeline } from "./FlamegraphStore";
+import { NodeTransform } from "./transform/focusNode";
 
 /**
  * Parse a visual flamegraph DSL into a `ProfilerNode` tree. Each
@@ -168,6 +169,7 @@ export function flames(
 		commit,
 		idMap,
 		nodes,
+		root: nodes[0],
 		byName: (name: string) => nameMap.get(name),
 		byId: (id: ID) => idMap.get(id),
 	};
@@ -176,3 +178,21 @@ export function flames(
 export function byName(tree: Tree, name: string) {
 	return Array.from(tree.values()).find(x => x.name === name);
 }
+
+export function visualize(nodes: NodeTransform[]) {
+	const rows = new Array(Math.max(...nodes.map(x => x.row + 1))).fill(
+		" ".repeat(100),
+	);
+
+	nodes.forEach(node => {
+		const w = Math.round(node.width);
+		const x = Math.round(node.x);
+		const s = rows[node.row];
+		rows[node.row] =
+			s.substring(0, x) +
+			(node.id + "*".repeat(w).slice(0, w)) +
+			s.substring(x + w);
+	});
+
+	return rows.join("\n");
+}
diff --git a/src/view/components/profiler/flamegraph/transform/focusNode.test.ts b/src/view/components/profiler/flamegraph/transform/focusNode.test.ts
index 30e5dd3..5b99667 100644
--- a/src/view/components/profiler/flamegraph/transform/focusNode.test.ts
+++ b/src/view/components/profiler/flamegraph/transform/focusNode.test.ts
@@ -1,93 +1,123 @@
 import { expect } from "chai";
 import { focusNode, NodeTransform } from "./focusNode";
 
-describe("FlameGraph", () => {
-	describe("focusNode", () => {
-		it("should focus flat tree", () => {
-			const nodes: NodeTransform[] = [
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 50, x: 25 },
-				{ id: 3, row: 2, width: 25, x: 40 },
-			];
+const required = {
+	weight: -1,
+	maximized: true,
+	visible: true,
+};
 
-			expect(focusNode(nodes, 2)).to.deep.equal([
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 100, x: 0 },
-				{ id: 3, row: 2, width: 50, x: 30 },
-			]);
-		});
+describe("focusNode", () => {
+	it("should focus flat tree", () => {
+		const nodes: NodeTransform[] = [
+			{ ...required, id: 1, row: 0, width: 100, x: 0 },
+			{ ...required, id: 2, row: 1, width: 50, x: 25 },
+			{ ...required, id: 3, row: 2, width: 25, x: 40 },
+		];
 
-		it("should focus flat tree #2", () => {
-			const nodes: NodeTransform[] = [
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 75, x: 25 },
-				{ id: 3, row: 2, width: 60, x: 40 },
-			];
+		expect(focusNode(nodes, 2)).to.deep.equal([
+			{ ...required, id: 1, row: 0, width: 100, x: 0, maximized: true },
+			{ ...required, id: 2, row: 1, width: 100, x: 0, maximized: true },
+			{ ...required, id: 3, row: 2, width: 50, x: 30, maximized: false },
+		]);
+	});
+
+	it("should focus flat tree #2", () => {
+		const nodes: NodeTransform[] = [
+			{ ...required, id: 1, row: 0, width: 100, x: 0 },
+			{ ...required, id: 2, row: 1, width: 75, x: 25 },
+			{ ...required, id: 3, row: 2, width: 60, x: 40 },
+		];
 
-			expect(focusNode(nodes, 2)).to.deep.equal([
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 100, x: 0 },
-				{ id: 3, row: 2, width: 80, x: 20 },
-			]);
-		});
+		expect(focusNode(nodes, 2)).to.deep.equal([
+			{ ...required, id: 1, row: 0, width: 100, x: 0, maximized: true },
+			{ ...required, id: 2, row: 1, width: 100, x: 0, maximized: true },
+			{ ...required, id: 3, row: 2, width: 80, x: 20, maximized: false },
+		]);
+	});
 
-		it("should focus sibling tree", () => {
-			const nodes: NodeTransform[] = [
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 50, x: 25 },
-				{ id: 3, row: 2, width: 25, x: 40 },
-				{ id: 4, row: 1, width: 25, x: 75 },
-			];
+	it("should focus sibling tree", () => {
+		const nodes: NodeTransform[] = [
+			{ ...required, id: 1, row: 0, width: 100, x: 0 },
+			{ ...required, id: 2, row: 1, width: 50, x: 25 },
+			{ ...required, id: 3, row: 2, width: 25, x: 40 },
+			{ ...required, id: 4, row: 1, width: 25, x: 75 },
+		];
 
-			expect(focusNode(nodes, 2)).to.deep.equal([
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 100, x: 0 },
-				{ id: 4, row: 1, width: 50, x: 100 },
-				{ id: 3, row: 2, width: 50, x: 30 },
-			]);
-		});
+		expect(focusNode(nodes, 2)).to.deep.equal([
+			{ ...required, id: 1, row: 0, width: 100, x: 0, maximized: true },
+			{ ...required, id: 2, row: 1, width: 100, x: 0, maximized: true },
+			{ ...required, id: 4, row: 1, width: 50, x: 100, maximized: false },
+			{ ...required, id: 3, row: 2, width: 50, x: 30, maximized: false },
+		]);
+	});
 
-		it("should focus tree", () => {
-			const nodes: NodeTransform[] = [
-				{
-					id: 1,
-					x: 0,
-					row: 0,
-					width: 5.02,
-				},
-				{
-					id: 2,
-					x: 1,
-					row: 1,
-					width: 2,
-				},
-				{
-					id: 3,
-					x: 3,
-					row: 1,
-					width: 2.0199999999999996,
-				},
-				{
-					id: 4,
-					x: 5,
-					row: 2,
-					width: 0.009999999999999787,
-				},
-				{
-					id: 5,
-					x: 5.01,
-					row: 2,
-					width: 0.009999999999999787,
-				},
-			];
+	it("should focus tree", () => {
+		const nodes: NodeTransform[] = [
+			{
+				...required,
+				id: 1,
+				x: 0,
+				row: 0,
+				width: 5.02,
+			},
+			{
+				...required,
+				id: 2,
+				x: 1,
+				row: 1,
+				width: 2,
+			},
+			{
+				...required,
+				id: 3,
+				x: 3,
+				row: 1,
+				width: 2.0199999999999996,
+			},
+			{
+				...required,
+				id: 4,
+				x: 5,
+				row: 2,
+				width: 0.009999999999999787,
+			},
+			{
+				...required,
+				id: 5,
+				x: 5.01,
+				row: 2,
+				width: 0.009999999999999787,
+			},
+		];
 
-			expect(focusNode(nodes, 2)).to.deep.equal([
-				{ id: 1, row: 0, width: 5.02, x: 0 },
-				{ id: 2, row: 1, width: 5.02, x: 0 },
-				{ id: 3, row: 1, width: 5.070199999999999, x: 5.02 },
-				{ id: 4, row: 2, width: 0.025099999999999463, x: 10.04 },
-				{ id: 5, row: 2, width: 0.025099999999999463, x: 10.0651 },
-			]);
-		});
+		expect(focusNode(nodes, 2)).to.deep.equal([
+			{ ...required, id: 1, row: 0, width: 5.02, x: 0, maximized: true },
+			{ ...required, id: 2, row: 1, width: 5.02, x: 0, maximized: true },
+			{
+				...required,
+				id: 3,
+				row: 1,
+				width: 5.070199999999999,
+				x: 5.02,
+				maximized: false,
+			},
+			{
+				...required,
+				id: 4,
+				row: 2,
+				width: 0.025099999999999463,
+				x: 10.04,
+				maximized: false,
+			},
+			{
+				...required,
+				id: 5,
+				row: 2,
+				width: 0.025099999999999463,
+				x: 10.0651,
+				maximized: false,
+			},
+		]);
 	});
 });
diff --git a/src/view/components/profiler/flamegraph/transform/pad.test.ts b/src/view/components/profiler/flamegraph/transform/pad.test.ts
index ea73f79..4b6e436 100644
--- a/src/view/components/profiler/flamegraph/transform/pad.test.ts
+++ b/src/view/components/profiler/flamegraph/transform/pad.test.ts
@@ -2,74 +2,80 @@ import { expect } from "chai";
 import { padNodes } from "./pad";
 import { NodeTransform } from "./focusNode";
 
+const required = {
+	weight: -1,
+	visible: true,
+	maximized: false,
+};
+
 describe("FlameGraph", () => {
 	describe("padNodes", () => {
 		it("should pad flat tree", () => {
 			const nodes: NodeTransform[] = [
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 0, x: 75 },
+				{ ...required, id: 1, row: 0, width: 100, x: 0 },
+				{ ...required, id: 2, row: 1, width: 0, x: 75 },
 			];
 			expect(padNodes(nodes, 10)).to.deep.equal([
-				{ id: 1, row: 0, width: 110, x: 0 },
-				{ id: 2, row: 1, width: 10, x: 75 },
+				{ ...required, id: 1, row: 0, width: 110, x: 0 },
+				{ ...required, id: 2, row: 1, width: 10, x: 75 },
 			]);
 		});
 
 		it("should pad following nodes", () => {
 			const nodes: NodeTransform[] = [
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 0, x: 25 },
-				{ id: 3, row: 1, width: 10, x: 50 },
+				{ ...required, id: 1, row: 0, width: 100, x: 0 },
+				{ ...required, id: 2, row: 1, width: 0, x: 25 },
+				{ ...required, id: 3, row: 1, width: 10, x: 50 },
 			];
 			expect(padNodes(nodes, 10)).to.deep.equal([
-				{ id: 1, row: 0, width: 110, x: 0 },
-				{ id: 2, row: 1, width: 10, x: 25 },
-				{ id: 3, row: 1, width: 10, x: 60 },
+				{ ...required, id: 1, row: 0, width: 110, x: 0 },
+				{ ...required, id: 2, row: 1, width: 10, x: 25 },
+				{ ...required, id: 3, row: 1, width: 10, x: 60 },
 			]);
 		});
 
 		it("should pad parent following nodes", () => {
 			const nodes: NodeTransform[] = [
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 40, x: 25 },
-				{ id: 3, row: 2, width: 0, x: 40 },
-				{ id: 4, row: 1, width: 20, x: 70 },
+				{ ...required, id: 1, row: 0, width: 100, x: 0 },
+				{ ...required, id: 2, row: 1, width: 40, x: 25 },
+				{ ...required, id: 3, row: 2, width: 0, x: 40 },
+				{ ...required, id: 4, row: 1, width: 20, x: 70 },
 			];
 			expect(padNodes(nodes, 10)).to.deep.equal([
-				{ id: 1, row: 0, width: 110, x: 0 },
-				{ id: 2, row: 1, width: 50, x: 25 },
-				{ id: 3, row: 2, width: 10, x: 40 },
-				{ id: 4, row: 1, width: 20, x: 80 },
+				{ ...required, id: 1, row: 0, width: 110, x: 0 },
+				{ ...required, id: 2, row: 1, width: 50, x: 25 },
+				{ ...required, id: 3, row: 2, width: 10, x: 40 },
+				{ ...required, id: 4, row: 1, width: 20, x: 80 },
 			]);
 		});
 
 		it("should pad zeroed nodes on the same location", () => {
 			const nodes: NodeTransform[] = [
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 0, x: 75 },
-				{ id: 3, row: 1, width: 0, x: 75 },
+				{ ...required, id: 1, row: 0, width: 100, x: 0 },
+				{ ...required, id: 2, row: 1, width: 0, x: 75 },
+				{ ...required, id: 3, row: 1, width: 0, x: 75 },
 			];
 			expect(padNodes(nodes, 10)).to.deep.equal([
-				{ id: 1, row: 0, width: 120, x: 0 },
-				{ id: 2, row: 1, width: 10, x: 75 },
-				{ id: 3, row: 1, width: 10, x: 85 },
+				{ ...required, id: 1, row: 0, width: 120, x: 0 },
+				{ ...required, id: 2, row: 1, width: 10, x: 75 },
+				{ ...required, id: 3, row: 1, width: 10, x: 85 },
 			]);
 		});
 
 		it("should pad zeroed nodes on the same location #2", () => {
 			const nodes: NodeTransform[] = [
-				{ id: 1, row: 0, width: 100, x: 0 },
-				{ id: 2, row: 1, width: 0, x: 75 },
-				{ id: 3, row: 1, width: 0, x: 75 },
-				{ id: 4, row: 1, width: 0, x: 75 },
-				{ id: 5, row: 1, width: 15, x: 85 },
+				{ ...required, id: 1, row: 0, width: 100, x: 0 },
+				{ ...required, id: 2, row: 1, width: 0, x: 75 },
+				{ ...required, id: 3, row: 1, width: 0, x: 75 },
+				{ ...required, id: 4, row: 1, width: 0, x: 75 },
+				{ ...required, id: 5, row: 1, width: 15, x: 85 },
 			];
 			expect(padNodes(nodes, 10)).to.deep.equal([
-				{ id: 1, row: 0, width: 130, x: 0 },
-				{ id: 2, row: 1, width: 10, x: 75 },
-				{ id: 3, row: 1, width: 10, x: 85 },
-				{ id: 4, row: 1, width: 10, x: 95 },
-				{ id: 5, row: 1, width: 15, x: 115 },
+				{ ...required, id: 1, row: 0, width: 130, x: 0 },
+				{ ...required, id: 2, row: 1, width: 10, x: 75 },
+				{ ...required, id: 3, row: 1, width: 10, x: 85 },
+				{ ...required, id: 4, row: 1, width: 10, x: 95 },
+				{ ...required, id: 5, row: 1, width: 15, x: 115 },
 			]);
 		});
 	});
diff --git a/src/view/components/profiler/flamegraph/transform/util.test.ts b/src/view/components/profiler/flamegraph/transform/util.test.ts
new file mode 100644
index 0000000..454cd06
--- /dev/null
+++ b/src/view/components/profiler/flamegraph/transform/util.test.ts
@@ -0,0 +1,132 @@
+import { flames } from "../testHelpers";
+import { expect } from "chai";
+import { mapParents, adjustNodesToRight } from "./util";
+import { DevNode } from "../../../../store/types";
+
+describe("mapParents", () => {
+	it("should do nothing if root", () => {
+		const tree = flames`
+    App *****************
+      Foo **   Bar ******
+                Bob ***
+    `;
+
+		const res: string[] = [];
+		mapParents(tree.idMap, tree.byName("App")!.id, p => {
+			res.push(p.name);
+		});
+		expect(res).to.deep.equal([]);
+	});
+
+	it("should traverse parents", () => {
+		const tree = flames`
+    App *****************
+      Foo **   Bar ******
+                Bob ***
+    `;
+
+		const res: string[] = [];
+		mapParents(tree.idMap, tree.byName("Bob")!.id, p => {
+			res.push(p.name);
+		});
+		expect(res).to.deep.equal(["Bar", "App"]);
+	});
+
+	it("should traverse parents", () => {
+		const tree = flames`
+    App *****************
+      Foo **   Bar ******
+                Bob ***
+    `;
+
+		const res: string[] = [];
+		mapParents(tree.idMap, tree.byName("Foo")!.id, p => {
+			res.push(p.name);
+		});
+		expect(res).to.deep.equal(["App"]);
+	});
+
+	it("should pass correct prevParents", () => {
+		const tree = flames`
+    App *****************
+      Foo **   Bar ******
+                Bob ***
+    `;
+
+		const res: string[] = [];
+		mapParents(tree.idMap, tree.byName("Foo")!.id, (p, pp) => {
+			res.push(pp.name + "->" + p.name);
+		});
+		expect(res).to.deep.equal(["Foo->App"]);
+	});
+
+	it("should pass correct prevParents #2", () => {
+		const tree = flames`
+    App *****************
+      Foo **   Bar ******
+                Bob ***
+    `;
+
+		const res: string[] = [];
+		mapParents(tree.idMap, tree.byName("Bob")!.id, (p, pp) => {
+			res.push(pp.name + "->" + p.name);
+		});
+		expect(res).to.deep.equal(["Bob->Bar", "Bar->App"]);
+	});
+});
+
+function toTimings(node: DevNode) {
+	return {
+		name: node.name,
+		treeStartTime: node.treeStartTime,
+		treeEndTime: node.treeEndTime,
+	};
+}
+
+describe("adjustNodesToRight", () => {
+	it("should traverse parents", () => {
+		const tree = flames`
+    App *****************
+      Foo **   Bar ******
+                Bob ***
+    `;
+
+		expect(tree.nodes.map(toTimings)).to.deep.equal([
+			{ name: "App", treeStartTime: 0, treeEndTime: 210 },
+			{ name: "Foo", treeStartTime: 20, treeEndTime: 80 },
+			{ name: "Bar", treeStartTime: 110, treeEndTime: 210 },
+			{ name: "Bob", treeStartTime: 120, treeEndTime: 190 },
+		]);
+		adjustNodesToRight(tree.idMap, tree.byName("Bob")!.id, 10);
+		expect(tree.nodes.map(toTimings)).to.deep.equal([
+			{ name: "App", treeStartTime: 0, treeEndTime: 220 },
+			{ name: "Foo", treeStartTime: 20, treeEndTime: 80 },
+			{ name: "Bar", treeStartTime: 110, treeEndTime: 220 },
+			{ name: "Bob", treeStartTime: 120, treeEndTime: 190 },
+		]);
+	});
+
+	it("should map to right", () => {
+		const tree = flames`
+    App *****************
+      Foo **   Bar ******
+       Faz *    Bob ***
+    `;
+
+		expect(tree.nodes.map(toTimings)).to.deep.equal([
+			{ name: "App", treeStartTime: 0, treeEndTime: 210 },
+			{ name: "Foo", treeStartTime: 20, treeEndTime: 80 },
+			{ name: "Faz", treeStartTime: 30, treeEndTime: 80 },
+			{ name: "Bar", treeStartTime: 110, treeEndTime: 210 },
+			{ name: "Bob", treeStartTime: 120, treeEndTime: 190 },
+		]);
+		adjustNodesToRight(tree.idMap, tree.byName("Faz")!.id, 10);
+		expect(tree.nodes.map(toTimings)).to.deep.equal([
+			{ name: "App", treeStartTime: 0, treeEndTime: 220 },
+			{ name: "Foo", treeStartTime: 20, treeEndTime: 90 },
+			{ name: "Faz", treeStartTime: 30, treeEndTime: 80 },
+			{ name: "Bar", treeStartTime: 120, treeEndTime: 220 },
+			{ name: "Bob", treeStartTime: 130, treeEndTime: 200 },
+		]);
+	});
+});
diff --git a/test-e2e/devtools.html b/test-e2e/devtools.html
index 29cba02..13cb4f8 100644
--- a/test-e2e/devtools.html
+++ b/test-e2e/devtools.html
@@ -26,6 +26,7 @@
 			const { setupInlineDevtools, createStore, applyEvent } = setup;
 
 			const store = setupInlineDevtools(document.getElementById("app"), window);
+			window.parent.store = store;
 			store.subscribe((name, msg) => {
 				window.parent.postMessage(
 					{ type: name, data: msg, source: "preact-devtools-to-client" },
diff --git a/test-e2e/tests/profiler/profiler-unaffected.test.ts b/test-e2e/tests/profiler/profiler-unaffected.test.ts
index cd8837e..9b4225f 100644
--- a/test-e2e/tests/profiler/profiler-unaffected.test.ts
+++ b/test-e2e/tests/profiler/profiler-unaffected.test.ts
@@ -26,7 +26,7 @@ export async function run(config: any) {
 
 	const nodes = await getText$$(
 		devtools,
-		'[data-type="flamegraph"] > *:not([data-weight="-1"])',
+		'[data-type="flamegraph"] > *:not([data-weight="-2"])',
 	);
 	expect(nodes.map(n => n.split(" ")[0])).to.deep.equal([
 		"Fragment",
diff --git a/test-e2e/tests/profiler/rendered-at.test.ts b/test-e2e/tests/profiler/rendered-at.test.ts
new file mode 100644
index 0000000..8bc9491
--- /dev/null
+++ b/test-e2e/tests/profiler/rendered-at.test.ts
@@ -0,0 +1,64 @@
+import {
+	newTestPage,
+	click,
+	waitForAttribute,
+	clickNestedText,
+	getCount,
+	getAttribute$$,
+} from "../../test-utils";
+import { expect } from "chai";
+import { closePage, waitForTestId } from "pentf/browser_utils";
+
+export const description = "Show in which commit a node rendered";
+
+export async function run(config: any) {
+	const { page, devtools } = await newTestPage(config, "root-multi");
+
+	await click(devtools, '[name="root-panel"][value="PROFILER"]');
+
+	const recordBtn = '[data-testid="record-btn"]';
+	await click(devtools, recordBtn);
+
+	await waitForAttribute(devtools, recordBtn, "title", /Stop Recording/);
+
+	await click(page, "#app button");
+	await click(page, "#app2 button");
+	await click(page, "#app button");
+	await click(page, "#app2 button");
+
+	await click(devtools, recordBtn);
+
+	await clickNestedText(devtools, "Counter");
+	await waitForTestId(devtools, "rendered-at");
+
+	const items = await getCount(devtools, '[data-testid="rendered-at"] button');
+	expect(items).to.equal(2);
+
+	let commits = await getAttribute$$(
+		devtools,
+		'[data-testid="commit-item"]',
+		"data-selected",
+	);
+	expect(commits).to.deep.equal(["true", null, null, null]);
+
+	const btns = await getAttribute$$(
+		devtools,
+		'[data-testid="rendered-at"] button',
+		"data-active",
+	);
+	expect(btns).to.deep.equal(["true", null]);
+
+	await click(
+		devtools,
+		'[data-testid="rendered-at"] button:not([data-active])',
+	);
+
+	commits = await getAttribute$$(
+		devtools,
+		'[data-testid="commit-item"]',
+		"data-selected",
+	);
+	expect(commits).to.deep.equal([null, null, "true", null]);
+
+	await closePage(page);
+}