Repository: preactjs/preact-devtools
Pull request #385 (2022-06-01 06:28:30)

Add "Rendered by" panel
<img width="120" alt="Screenshot 2022-06-01 at 08 28 13" src="https://user-images.githubusercontent.com/1062408/171341441-28b1db7f-f0f0-49a2-8769-8e1354988759.png">



Patch:
diff --git a/src/adapter/10/options.ts b/src/adapter/10/options.ts
index 978d125..a5c45a3 100644
--- a/src/adapter/10/options.ts
+++ b/src/adapter/10/options.ts
@@ -50,6 +50,8 @@ export function setupOptionsV10(
 	}
 
 	let timings = createVNodeTimings<VNode>();
+	const owners = new Map<VNode, VNode>();
+	let ownerStack: VNode[] = [];
 
 	const o = options as any;
 
@@ -58,12 +60,15 @@ export function setupOptionsV10(
 	const prevCommitRoot = o._commit || o.__c;
 	const prevBeforeUnmount = options.unmount;
 	const prevBeforeDiff = o._diff || o.__b;
+	const prevRender = o._render || o.__r;
 	const prevAfterDiff = options.diffed;
 	let prevHook = o._hook || o.__h;
 	let prevUseDebugValue = options.useDebugValue;
 	// @ts-ignore
 	let prevHookName = options.useDebugName;
 
+	const skipEffects = o._skipEffects || o.__s;
+
 	// Make sure that we are always the first `option._hook` to be called.
 	// This is necessary to ensure that our callstack remains consistent.
 	// Othwerwise we'll end up with an unknown number of frames in-between
@@ -108,6 +113,17 @@ export function setupOptionsV10(
 		};
 	}, 100);
 
+	options.vnode = (vnode: VNode) => {
+		if (
+			ownerStack.length > 0 &&
+			typeof vnode.type === "function" &&
+			vnode.type !== config.Fragment
+		) {
+			owners.set(vnode, ownerStack[ownerStack.length - 1]);
+		}
+		if (prevVNodeHook) prevVNodeHook(vnode);
+	};
+
 	o._diff = o.__b = (vnode: VNode) => {
 		if (typeof vnode.type === "function") {
 			timings.start.set(vnode, performance.now());
@@ -118,8 +134,23 @@ export function setupOptionsV10(
 		if (prevBeforeDiff != null) prevBeforeDiff(vnode);
 	};
 
+	o._render = o.__r = (vnode: VNode, parent: VNode | null) => {
+		if (
+			!skipEffects &&
+			typeof vnode.type === "function" &&
+			vnode.type !== config.Fragment
+		) {
+			ownerStack.push(vnode);
+		}
+		if (prevRender != null) prevRender(vnode, parent);
+	};
+
 	options.diffed = vnode => {
 		if (typeof vnode.type === "function") {
+			if (vnode.type !== config.Fragment) {
+				ownerStack.pop();
+			}
+
 			timings.end.set(vnode, performance.now());
 			endMark(getDisplayName(vnode, config));
 		}
@@ -133,15 +164,17 @@ export function setupOptionsV10(
 		// These cases are already handled by `unmount`
 		if (vnode == null) return;
 
-		const tmp = timings;
+		const tmpTimings = timings;
+		ownerStack = [];
 		timings = createVNodeTimings();
-		renderer.onCommit(vnode, tmp, null);
+		renderer.onCommit(vnode, owners, tmpTimings, null);
 	};
 
 	options.unmount = vnode => {
 		if (prevBeforeUnmount) prevBeforeUnmount(vnode);
 		timings.start.delete(vnode);
 		timings.end.delete(vnode);
+		owners.delete(vnode);
 		renderer.onUnmount(vnode as any);
 	};
 
diff --git a/src/adapter/11/options.ts b/src/adapter/11/options.ts
index ae57c05..45a8b56 100644
--- a/src/adapter/11/options.ts
+++ b/src/adapter/11/options.ts
@@ -101,6 +101,7 @@ export function setupOptionsV11(
 	const timings = createVNodeTimings<Internal>();
 	let renderReasons = new Map<Internal, RenderReasonData>();
 	let reasonTmpData = new Map<Internal, RenderReasonTmpData>();
+	const owners = new Map();
 
 	const o = options;
 
@@ -208,7 +209,7 @@ export function setupOptionsV11(
 		// These cases are already handled by `unmount`
 		if (internal == null) return;
 
-		renderer.onCommit(internal, timings, renderReasons);
+		renderer.onCommit(internal, owners, timings, renderReasons);
 
 		if (profiler.captureRenderReasons) {
 			renderReasons = new Map();
diff --git a/src/adapter/debug.ts b/src/adapter/debug.ts
index db0b613..c32adab 100644
--- a/src/adapter/debug.ts
+++ b/src/adapter/debug.ts
@@ -208,7 +208,10 @@ export function fromSnapshot(events: string[]): number[] {
 
 export function printCommit(data: number[]) {
 	/* eslint-disable no-console */
-	console.group("commit", data);
+	console.group("commit");
+	console.groupCollapsed("raw");
+	console.log(data);
+	console.groupEnd();
 	try {
 		console.log("root id: ", data[0]);
 		let i = 1;
@@ -229,17 +232,21 @@ export function printCommit(data: number[]) {
 				case MsgTypes.ADD_VNODE: {
 					const id = data[i + 1];
 					const name = strings[data[i + 5] - 1];
-					const key = data[i + 6] > 0 ? ` key="${strings[i + 6 - 1]}" ` : "";
+					const key =
+						data[i + 6] > 0 ? ` key="${strings[data[i + 6] - 1]}" ` : "";
 					const parentId = data[i + 3];
+					const ownerId = data[i + 4];
 					const startTime = data[i + 7];
 					const endTime = data[i + 8];
 					console.log(
-						`Add %c${id} %c<${name}${key}>%c to parent %c${parentId}%c, time: %c${startTime}%c - %c${endTime}`,
+						`Add %c${id} %c<${name}${key}>%c to parent %c${parentId}%c with owner %c${ownerId}%c, time: %c${startTime}%c - %c${endTime}`,
 						"color: yellow",
 						"color: violet",
 						"color: inherit",
 						"color: green",
 						"color: inherit",
+						"color: yellow",
+						"color: inherit",
 						"color: peachpuff",
 						"color: inherit",
 						"color: peachpuff",
diff --git a/src/adapter/protocol/legacy/operationsV1.ts b/src/adapter/protocol/legacy/operationsV1.ts
index 76b94bf..718cf4f 100644
--- a/src/adapter/protocol/legacy/operationsV1.ts
+++ b/src/adapter/protocol/legacy/operationsV1.ts
@@ -51,6 +51,7 @@ export function applyOperationsV1(store: Store, data: number[]) {
 						id,
 						hocs: null,
 						name,
+						owner: -1,
 						parent: parentId,
 						type,
 						key,
diff --git a/src/adapter/protocol/operations.ts b/src/adapter/protocol/operations.ts
index 142c5d6..55b6318 100644
--- a/src/adapter/protocol/operations.ts
+++ b/src/adapter/protocol/operations.ts
@@ -46,6 +46,7 @@ export function ops2Tree(oldTree: Tree, existingRoots: ID[], ops: number[]) {
 					id,
 					hocs: null,
 					name: strings[ops[i + 5] - 1],
+					owner: ops[i + 4],
 					parent: parentId,
 					type: ops[i + 2],
 					key: ops[i + 6] > 0 ? strings[ops[i + 6] - 1] : "",
diff --git a/src/adapter/renderer.ts b/src/adapter/renderer.ts
index dc50392..668f525 100644
--- a/src/adapter/renderer.ts
+++ b/src/adapter/renderer.ts
@@ -20,6 +20,7 @@ export interface Renderer<T extends SharedVNode = SharedVNode> {
 	clear?(): void;
 	onCommit(
 		vnode: T,
+		owners: Map<T, T>,
 		timings: VNodeTimings<T>,
 		renderReasons: Map<T, RenderReasonData> | null,
 	): void;
diff --git a/src/adapter/shared/hooks.ts b/src/adapter/shared/hooks.ts
index 72dce0d..11de079 100644
--- a/src/adapter/shared/hooks.ts
+++ b/src/adapter/shared/hooks.ts
@@ -295,7 +295,7 @@ export function inspectHooks<T extends SharedVNode>(
 		};
 
 		// Force preact to reset internal hooks index
-		const renderHook = (options as any)._render || (options as any).__r;
+		const renderHook = (options as any).__r || (options as any)._render;
 		if (renderHook) {
 			const dummyVNode = h("div", null);
 			// Note: A "div" normally won't have the _component property set,
@@ -309,7 +309,7 @@ export function inspectHooks<T extends SharedVNode>(
 				__hooks: hooks,
 				__H: hooks,
 			};
-			renderHook(dummyVNode);
+			renderHook(dummyVNode, null);
 		}
 
 		if (isClass) {
diff --git a/src/adapter/shared/renderer.ts b/src/adapter/shared/renderer.ts
index 9b18498..50cb689 100644
--- a/src/adapter/shared/renderer.ts
+++ b/src/adapter/shared/renderer.ts
@@ -70,6 +70,7 @@ export function createRenderer<T extends SharedVNode>(
 	const roots = new Set<T>();
 
 	let currentUnmounts: number[] = [];
+	let prevOwners = new Map<T, T>();
 
 	const domToVNode = new WeakMap<HTMLElement | Text, T>();
 
@@ -207,6 +208,7 @@ export function createRenderer<T extends SharedVNode>(
 				const commit = createCommit(
 					ids,
 					roots,
+					prevOwners,
 					root,
 					filters,
 					domToVNode,
@@ -223,10 +225,11 @@ export function createRenderer<T extends SharedVNode>(
 
 			queue.forEach(ev => port.send(ev.type, ev.data));
 		},
-		onCommit(vnode, timingsByVNode, renderReasonPre) {
+		onCommit(vnode, owners, timingsByVNode, renderReasonPre) {
 			const commit = createCommit(
 				ids,
 				roots,
+				owners,
 				vnode,
 				filters,
 				domToVNode,
@@ -237,6 +240,8 @@ export function createRenderer<T extends SharedVNode>(
 				renderReasonPre,
 			);
 
+			prevOwners = owners;
+
 			timingsByVNode.start.clear();
 			timingsByVNode.end.clear();
 
diff --git a/src/adapter/shared/traverse.ts b/src/adapter/shared/traverse.ts
index d119ba9..fa80db7 100644
--- a/src/adapter/shared/traverse.ts
+++ b/src/adapter/shared/traverse.ts
@@ -182,7 +182,9 @@ export function shouldFilter<T extends SharedVNode>(
 function mount<T extends SharedVNode>(
 	ids: IdMappingState<T>,
 	commit: Commit,
+	owners: Map<T, T>,
 	vnode: T,
+	ownerId: ID,
 	ancestorId: ID,
 	filters: FilterState,
 	domCache: WeakMap<HTMLElement | Text, T>,
@@ -215,16 +217,24 @@ function mount<T extends SharedVNode>(
 
 	if (root || !skip) {
 		const id = getOrCreateVNodeId(ids, vnode);
-		if (bindings.isRoot(vnode, config)) {
+		if (root) {
 			commit.operations.push(MsgTypes.ADD_ROOT, id);
 		}
 
+		if (!root) {
+			const maybeOwner = owners.get(vnode);
+			if (maybeOwner !== undefined && !bindings.isRoot(maybeOwner, config)) {
+				const maybeOwnerId = getVNodeId(ids, maybeOwner);
+				ownerId = maybeOwnerId !== -1 ? maybeOwnerId : ownerId;
+			}
+		}
+
 		commit.operations.push(
 			MsgTypes.ADD_VNODE,
 			id,
 			getDevtoolsType(vnode, bindings), // Type
 			ancestorId,
-			9999, // owner
+			ownerId,
 			getStringId(commit.strings, name),
 			vnode.key ? getStringId(commit.strings, vnode.key) : 0,
 			// Multiply, because operations array only supports integers
@@ -233,9 +243,14 @@ function mount<T extends SharedVNode>(
 			(timingsByVNode.end.get(vnode) || 0) * 1000,
 		);
 
+		if (ownerId === -1 && !root) {
+			ownerId = id;
+		}
+
 		if (hocs.length > 0) {
 			addHocs(commit, id, hocs);
 			hocs = [];
+			ownerId = id;
 		}
 
 		// Capture render reason (mount here)
@@ -268,7 +283,9 @@ function mount<T extends SharedVNode>(
 			mount(
 				ids,
 				commit,
+				owners,
 				child,
+				ownerId,
 				ancestorId,
 				filters,
 				domCache,
@@ -323,7 +340,9 @@ function resetChildren<T extends SharedVNode>(
 function update<T extends SharedVNode>(
 	ids: IdMappingState<T>,
 	commit: Commit,
+	owners: Map<T, T>,
 	vnode: T,
+	ownerId: ID,
 	ancestorId: number,
 	filters: FilterState,
 	domCache: WeakMap<HTMLElement | Text, T>,
@@ -362,7 +381,9 @@ function update<T extends SharedVNode>(
 				update(
 					ids,
 					commit,
+					owners,
 					child,
+					ownerId,
 					ancestorId,
 					filters,
 					domCache,
@@ -387,7 +408,9 @@ function update<T extends SharedVNode>(
 		mount(
 			ids,
 			commit,
+			owners,
 			vnode,
+			ownerId,
 			ancestorId,
 			filters,
 			domCache,
@@ -473,7 +496,9 @@ function update<T extends SharedVNode>(
 			update(
 				ids,
 				commit,
+				owners,
 				child,
+				ownerId,
 				id,
 				filters,
 				domCache,
@@ -494,7 +519,9 @@ function update<T extends SharedVNode>(
 			mount(
 				ids,
 				commit,
+				owners,
 				child,
+				ownerId,
 				id,
 				filters,
 				domCache,
@@ -545,6 +572,7 @@ function findClosestNonFilteredParent<T extends SharedVNode>(
 export function createCommit<T extends SharedVNode>(
 	ids: IdMappingState<T>,
 	roots: Set<T>,
+	owners: Map<T, T>,
 	vnode: T,
 	filters: FilterState,
 	domCache: WeakMap<HTMLElement | Text, T>,
@@ -564,6 +592,8 @@ export function createCommit<T extends SharedVNode>(
 	};
 
 	let parentId = -1;
+	// Roots have no known ownerId
+	let ownerId = -1;
 
 	const isNew = !hasVNodeId(ids, vnode);
 
@@ -578,13 +608,20 @@ export function createCommit<T extends SharedVNode>(
 		roots.add(vnode);
 	} else {
 		parentId = findClosestNonFilteredParent(ids, helpers, vnode);
+		if (!isNew) {
+			ownerId = shouldFilter(vnode, filters, config, helpers)
+				? parentId
+				: getVNodeId(ids, vnode);
+		}
 	}
 
 	if (isNew) {
 		mount(
 			ids,
 			commit,
+			owners,
 			vnode,
+			ownerId,
 			parentId,
 			filters,
 			domCache,
@@ -599,7 +636,9 @@ export function createCommit<T extends SharedVNode>(
 		update(
 			ids,
 			commit,
+			owners,
 			vnode,
+			ownerId,
 			parentId,
 			filters,
 			domCache,
diff --git a/src/shells/shared/panel/panel.html b/src/shells/shared/panel/panel.html
index 80370d0..f7842d8 100644
--- a/src/shells/shared/panel/panel.html
+++ b/src/shells/shared/panel/panel.html
@@ -3,7 +3,6 @@
 	<head>
 		<meta charset="utf-8" />
 		<title>Preact Devtools Extension</title>
-		<meta name="color-scheme" content="dark light" />
 		<style>
 			* {
 				box-sizing: border-box;
diff --git a/src/view/components/elements/OwnerInfo.tsx b/src/view/components/elements/OwnerInfo.tsx
new file mode 100644
index 0000000..1b4e2eb
--- /dev/null
+++ b/src/view/components/elements/OwnerInfo.tsx
@@ -0,0 +1,55 @@
+import { h } from "preact";
+import { useObserver, useStore } from "../../store/react-bindings";
+import { DevNode } from "../../store/types";
+import { SidebarPanel } from "../sidebar/SidebarPanel";
+import s from "../profiler/components/RenderedAt/RenderedAt.module.css";
+
+export function OwnerInfo() {
+	const store = useStore();
+
+	const selectedId = useObserver(() => store.selection.selected.$);
+	const data = useObserver(() => {
+		const owners: DevNode[] = [];
+		const selectedId = store.selection.selected.$;
+
+		const nodes = store.nodes.$;
+
+		let id = selectedId;
+		let current: DevNode | undefined;
+		while ((current = nodes.get(id)) !== undefined) {
+			if (!nodes.has(current.owner)) {
+				break;
+			}
+			owners.push(nodes.get(current.owner)!);
+			id = current.owner;
+		}
+
+		return owners;
+	});
+
+	if (selectedId === -1) {
+		return null;
+	}
+
+	return (
+		<SidebarPanel title="Rendered by">
+			<nav data-testid="owners">
+				{data.length === 0 && <p>-</p>}
+				{data.map(node => {
+					return (
+						<button
+							key={node.id}
+							class={s.item}
+							data-active={selectedId === node.id}
+							onClick={() => {
+								store.selection.selectById(node.id);
+							}}
+						>
+							{node.name}
+						</button>
+					);
+				})}
+			</nav>
+		</SidebarPanel>
+	);
+}
diff --git a/src/view/components/profiler/components/RenderedAt/RenderedAt.module.css b/src/view/components/profiler/components/RenderedAt/RenderedAt.module.css
index ae0b1b6..f73a0be 100644
--- a/src/view/components/profiler/components/RenderedAt/RenderedAt.module.css
+++ b/src/view/components/profiler/components/RenderedAt/RenderedAt.module.css
@@ -1,7 +1,7 @@
 .item {
 	background: none;
 	border: none;
-	color: var(--color-text);
+	color: var(--color-element-text);
 	width: 100%;
 	text-align: start;
 	display: flex;
@@ -11,6 +11,11 @@
 	cursor: pointer;
 }
 
+.item:hover {
+	background: var(--color-dim-bg);
+	border-radius: 0.1875rem;
+}
+
 .item[data-active] {
 	color: var(--color-selected-text);
 	background: var(--color-selected-bg);
diff --git a/src/view/components/profiler/flamegraph/placeNodes.ts b/src/view/components/profiler/flamegraph/placeNodes.ts
index 8f61702..d8bb458 100644
--- a/src/view/components/profiler/flamegraph/placeNodes.ts
+++ b/src/view/components/profiler/flamegraph/placeNodes.ts
@@ -36,6 +36,7 @@ export const EMPTY: DevNode = {
 	depth: 0,
 	endTime: 0,
 	id: -1,
+	owner: -1,
 	hocs: null,
 	key: "",
 	name: "",
diff --git a/src/view/components/sidebar/Sidebar.tsx b/src/view/components/sidebar/Sidebar.tsx
index 3326d12..4e1a3ec 100644
--- a/src/view/components/sidebar/Sidebar.tsx
+++ b/src/view/components/sidebar/Sidebar.tsx
@@ -4,6 +4,7 @@ import { PropsPanel } from "./inspect/PropsPanel";
 import { serializeProps } from "./inspect/serializeProps";
 import { DebugTreeStats } from "./DebugTreeStats";
 import { DebugNodeNavTree } from "./DebugNodeNavTree";
+import { OwnerInfo } from "./../elements/OwnerInfo";
 import { KeyPanel } from "./KeyPanel";
 import { HocPanel } from "./HocPanel";
 
@@ -79,6 +80,7 @@ export function Sidebar() {
 					}
 				/>
 			)}
+			{inspect && <OwnerInfo />}
 			{showDebug && <DebugNodeNavTree />}
 			{showDebug && <DebugTreeStats />}
 		</Fragment>
diff --git a/src/view/store/types.ts b/src/view/store/types.ts
index 2095d3d..c4e23f7 100644
--- a/src/view/store/types.ts
+++ b/src/view/store/types.ts
@@ -34,6 +34,7 @@ export interface DevNode {
 	type: DevNodeType;
 	name: string;
 	key: string | null;
+	owner: ID;
 	parent: ID;
 	children: ID[];
 	/** Higher Order Component wrappers */


Test patch:
diff --git a/src/adapter/protocol/operations.test.ts b/src/adapter/protocol/operations.test.ts
index a79bd9b..028a5b8 100644
--- a/src/adapter/protocol/operations.test.ts
+++ b/src/adapter/protocol/operations.test.ts
@@ -41,6 +41,7 @@ describe("ops2Tree", () => {
 					id: 1,
 					name: "Fragment",
 					parent: -1,
+					owner: 9999,
 					type: 3,
 					hocs: null,
 					key: "",
@@ -65,6 +66,7 @@ describe("ops2Tree", () => {
 					id: 1,
 					name: "Fragment",
 					parent: -1,
+					owner: 9999,
 					type: 3,
 					key: "",
 					hocs: null,
@@ -77,6 +79,7 @@ describe("ops2Tree", () => {
 					id: 2,
 					name: "span",
 					parent: 1,
+					owner: 9999,
 					type: 1,
 					key: "",
 					hocs: null,
diff --git a/src/view/components/elements/TreeView.test.tsx b/src/view/components/elements/TreeView.test.tsx
index 84da595..fdc74df 100644
--- a/src/view/components/elements/TreeView.test.tsx
+++ b/src/view/components/elements/TreeView.test.tsx
@@ -17,6 +17,7 @@ describe("TreeItem", () => {
 			id: 1,
 			hocs: null,
 			name: "foo",
+			owner: -1,
 			parent: -1,
 			startTime: 0,
 			type: DevNodeType.ClassComponent,
diff --git a/src/view/components/profiler/flamegraph/testHelpers.test.ts b/src/view/components/profiler/flamegraph/testHelpers.test.ts
index 60a1f2a..4335d45 100644
--- a/src/view/components/profiler/flamegraph/testHelpers.test.ts
+++ b/src/view/components/profiler/flamegraph/testHelpers.test.ts
@@ -21,6 +21,7 @@ describe("FlameGraph DSL", () => {
 				children: [2],
 				hocs: null,
 				parent: -1,
+				owner: -1,
 				depth: 0,
 			},
 			{
@@ -33,6 +34,7 @@ describe("FlameGraph DSL", () => {
 				children: [3],
 				hocs: null,
 				parent: 1,
+				owner: -1,
 				depth: 1,
 			},
 			{
@@ -45,6 +47,7 @@ describe("FlameGraph DSL", () => {
 				depth: 2,
 				children: [],
 				hocs: null,
+				owner: -1,
 				parent: 2,
 			},
 		];
@@ -70,6 +73,7 @@ describe("FlameGraph DSL", () => {
 				children: [2],
 				hocs: null,
 				parent: -1,
+				owner: -1,
 				depth: 0,
 			},
 			{
@@ -82,6 +86,7 @@ describe("FlameGraph DSL", () => {
 				children: [3],
 				hocs: null,
 				parent: 1,
+				owner: -1,
 				depth: 1,
 			},
 			{
@@ -92,6 +97,7 @@ describe("FlameGraph DSL", () => {
 				startTime: 0,
 				endTime: 70,
 				parent: 2,
+				owner: -1,
 				depth: 2,
 				children: [],
 				hocs: null,
@@ -159,6 +165,7 @@ describe("FlameGraph DSL", () => {
 				children: [2, 3],
 				hocs: null,
 				parent: -1,
+				owner: -1,
 				depth: 0,
 			},
 			{
@@ -171,6 +178,7 @@ describe("FlameGraph DSL", () => {
 				children: [],
 				hocs: null,
 				parent: 1,
+				owner: -1,
 				depth: 1,
 			},
 			{
@@ -184,6 +192,7 @@ describe("FlameGraph DSL", () => {
 				children: [],
 				hocs: null,
 				parent: 1,
+				owner: -1,
 			},
 		];
 
@@ -209,6 +218,7 @@ describe("FlameGraph DSL", () => {
 				hocs: null,
 				depth: 0,
 				parent: -1,
+				owner: -1,
 			},
 			{
 				id: 2,
@@ -220,6 +230,7 @@ describe("FlameGraph DSL", () => {
 				children: [3],
 				hocs: null,
 				parent: 1,
+				owner: -1,
 				depth: 1,
 			},
 			{
@@ -233,6 +244,7 @@ describe("FlameGraph DSL", () => {
 				children: [],
 				hocs: null,
 				parent: 2,
+				owner: -1,
 			},
 			{
 				id: 4,
@@ -245,6 +257,7 @@ describe("FlameGraph DSL", () => {
 				children: [5],
 				hocs: null,
 				parent: 1,
+				owner: -1,
 			},
 			{
 				id: 5,
@@ -257,6 +270,7 @@ describe("FlameGraph DSL", () => {
 				children: [],
 				hocs: null,
 				parent: 4,
+				owner: -1,
 			},
 		];
 
diff --git a/src/view/components/profiler/flamegraph/testHelpers.ts b/src/view/components/profiler/flamegraph/testHelpers.ts
index 8aae001..167abe7 100644
--- a/src/view/components/profiler/flamegraph/testHelpers.ts
+++ b/src/view/components/profiler/flamegraph/testHelpers.ts
@@ -79,6 +79,7 @@ export function flames(
 				depth: i,
 				key: "",
 				parent: -1,
+				owner: -1,
 				type:
 					name[0].toUpperCase() === name[0]
 						? DevNodeType.FunctionComponent
diff --git a/test-e2e/fixtures/apps/update-fake-hoc.jsx b/test-e2e/fixtures/apps/update-fake-hoc.jsx
new file mode 100644
index 0000000..342b5e0
--- /dev/null
+++ b/test-e2e/fixtures/apps/update-fake-hoc.jsx
@@ -0,0 +1,46 @@
+import { Fragment, h, render } from "preact";
+import { useState } from "preact/hooks";
+
+function Display(props) {
+	return <div data-testid="result">Counter: {props.value}</div>;
+}
+
+function ListItem(props) {
+	return <p>{props.children}</p>;
+}
+
+function List(props) {
+	return (
+		<Fragment>
+			{new Array(props.v).fill(0).map((_, i) => (
+				<ListItem key={i}>{i}</ListItem>
+			))}
+		</Fragment>
+	);
+}
+
+function Foo(props) {
+	return props.children;
+}
+
+function Counter() {
+	const [v, set] = useState(0);
+
+	return (
+		<div style="padding: 2rem;">
+			<Foo>
+				<Display value={v} />
+				<button onClick={() => set(v + 1)}>Increment</button>
+				<List v={v} />
+			</Foo>
+		</div>
+	);
+}
+
+Counter.displayName = "Counter(Foo)";
+
+function App() {
+	return <Counter />;
+}
+
+render(<App />, document.getElementById("app"));
diff --git a/test-e2e/fixtures/apps/update-hoc.jsx b/test-e2e/fixtures/apps/update-hoc.jsx
new file mode 100644
index 0000000..a1f131b
--- /dev/null
+++ b/test-e2e/fixtures/apps/update-hoc.jsx
@@ -0,0 +1,47 @@
+import { Fragment, h, render } from "preact";
+import { useState } from "preact/hooks";
+import { memo } from "preact/compat";
+
+function Display(props) {
+	return <div data-testid="result">Counter: {props.value}</div>;
+}
+
+function ListItem(props) {
+	return <p>{props.children}</p>;
+}
+
+function List(props) {
+	return (
+		<Fragment>
+			{new Array(props.v).fill(0).map((_, i) => (
+				<ListItem key={i}>{i}</ListItem>
+			))}
+		</Fragment>
+	);
+}
+
+function Foo(props) {
+	return props.children;
+}
+
+const MemoFoo = memo(Foo);
+
+function Counter() {
+	const [v, set] = useState(0);
+
+	return (
+		<div style="padding: 2rem;">
+			<MemoFoo>
+				<Display value={v} />
+				<button onClick={() => set(v + 1)}>Increment</button>
+				<List v={v} />
+			</MemoFoo>
+		</div>
+	);
+}
+
+function App() {
+	return <Counter />;
+}
+
+render(<App />, document.getElementById("app"));
diff --git a/test-e2e/fixtures/apps/update-middle.jsx b/test-e2e/fixtures/apps/update-middle.jsx
new file mode 100644
index 0000000..1d76149
--- /dev/null
+++ b/test-e2e/fixtures/apps/update-middle.jsx
@@ -0,0 +1,40 @@
+import { Fragment, h, render } from "preact";
+import { useState } from "preact/hooks";
+
+function Display(props) {
+	return <div data-testid="result">Counter: {props.value}</div>;
+}
+
+function ListItem(props) {
+	return <p>{props.children}</p>;
+}
+
+function List(props) {
+	return (
+		<Fragment>
+			{new Array(props.v).fill(0).map((_, i) => (
+				<ListItem key={i}>{i}</ListItem>
+			))}
+		</Fragment>
+	);
+}
+
+List.displayName = "List(Hohoho)";
+
+function Counter() {
+	const [v, set] = useState(0);
+
+	return (
+		<div style="padding: 2rem;">
+			<Display value={v} />
+			<button onClick={() => set(v + 1)}>Increment</button>
+			<List v={v} />
+		</div>
+	);
+}
+
+function App() {
+	return <Counter />;
+}
+
+render(<App />, document.getElementById("app"));
diff --git a/test-e2e/test-utils.ts b/test-e2e/test-utils.ts
index 1aa16b2..17ada7b 100644
--- a/test-e2e/test-utils.ts
+++ b/test-e2e/test-utils.ts
@@ -7,6 +7,7 @@ import {
 	newPage,
 	resizePage,
 	clickSelector,
+	waitForTestId,
 } from "pentf/browser_utils";
 import { Page } from "puppeteer";
 import { getPreactVersions } from "./fixtures/utils";
@@ -406,6 +407,20 @@ export async function clickAndWaitForHooks(devtools: Page, component: string) {
 	});
 }
 
+export async function getOwners(devtools: Page) {
+	await waitForTestId(devtools, "owners");
+	return await devtools.evaluate(() => {
+		const parent = document.querySelector('[data-testid="owners"]')!;
+		if (parent.textContent === "") {
+			return [];
+		}
+
+		return Array.from(parent.querySelectorAll("button")).map(
+			el => el.textContent,
+		);
+	});
+}
+
 export async function moveMouseAbs(page: Page, x: number, y: number) {
 	const { _x, _y } = page.mouse as any;
 	await page.mouse.move(_x - x, _y - y);
diff --git a/test-e2e/tests/inspect-owner-hoc.test.ts b/test-e2e/tests/inspect-owner-hoc.test.ts
new file mode 100644
index 0000000..583960f
--- /dev/null
+++ b/test-e2e/tests/inspect-owner-hoc.test.ts
@@ -0,0 +1,23 @@
+import { clickTreeItem, getOwners, newTestPage } from "../test-utils";
+import { expect } from "chai";
+import { clickSelector, waitForSelector } from "pentf/browser_utils";
+
+export const description = "Inspect owner with fake HOC";
+
+export async function run(config: any) {
+	const { devtools, page } = await newTestPage(config, "update-hoc");
+
+	await waitForSelector(page, "button");
+
+	await clickTreeItem(devtools, "List");
+	let owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["Counter", "App"]);
+
+	// Trigger update
+	await clickSelector(page, "button");
+	await waitForSelector(devtools, '[data-testid="elements-tree"]');
+
+	await clickTreeItem(devtools, "ListItem");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["List", "Counter", "App"]);
+}
diff --git a/test-e2e/tests/inspect-owner-memo.test.ts b/test-e2e/tests/inspect-owner-memo.test.ts
new file mode 100644
index 0000000..bcf5019
--- /dev/null
+++ b/test-e2e/tests/inspect-owner-memo.test.ts
@@ -0,0 +1,25 @@
+import { clickTreeItem, getOwners, newTestPage } from "../test-utils";
+import { expect } from "chai";
+
+export const description = "Inspect owner information with filtered nodes";
+
+export async function run(config: any) {
+	const { devtools } = await newTestPage(config, "static-subtree");
+
+	await clickTreeItem(devtools, "App");
+
+	let owners = await getOwners(devtools);
+	expect(owners).to.deep.equal([]);
+
+	await clickTreeItem(devtools, "Static");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["App"]);
+
+	await clickTreeItem(devtools, "Foo");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["Static", "App"]);
+
+	await clickTreeItem(devtools, "Display");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["App"]);
+}
diff --git a/test-e2e/tests/inspect-owner-no-hoc.test.ts b/test-e2e/tests/inspect-owner-no-hoc.test.ts
new file mode 100644
index 0000000..ad207b9
--- /dev/null
+++ b/test-e2e/tests/inspect-owner-no-hoc.test.ts
@@ -0,0 +1,37 @@
+import { clickTreeItem, getOwners, newTestPage } from "../test-utils";
+import { expect } from "chai";
+import {
+	clickNestedText,
+	clickSelector,
+	clickTestId,
+	waitForSelector,
+	waitForTestId,
+} from "pentf/browser_utils";
+
+export const description = "Inspect owner with disabled HOC filter";
+
+export async function run(config: any) {
+	const { devtools, page } = await newTestPage(config, "update-hoc");
+
+	await clickTestId(devtools, "filter-menu-button");
+	await waitForTestId(devtools, "filter-popup");
+	await clickNestedText(devtools, "HOC-Components");
+	await clickTestId(devtools, "filter-update");
+
+	await waitForSelector(
+		devtools,
+		'[data-testid="tree-item"][data-name="Memo(Foo)"]',
+	);
+
+	await clickTreeItem(devtools, "List");
+	let owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["Counter", "App"]);
+
+	// Trigger update
+	await clickSelector(page, "button");
+	await waitForSelector(devtools, '[data-testid="elements-tree"]');
+
+	await clickTreeItem(devtools, "ListItem");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["List", "Counter", "App"]);
+}
diff --git a/test-e2e/tests/inspect-owner-update.test.ts b/test-e2e/tests/inspect-owner-update.test.ts
new file mode 100644
index 0000000..daa7109
--- /dev/null
+++ b/test-e2e/tests/inspect-owner-update.test.ts
@@ -0,0 +1,16 @@
+import { clickTreeItem, getOwners, newTestPage } from "../test-utils";
+import { expect } from "chai";
+import { clickSelector, waitForSelector } from "pentf/browser_utils";
+
+export const description = "Inspect owner information with updated nodes";
+
+export async function run(config: any) {
+	const { devtools, page } = await newTestPage(config, "update-middle");
+
+	await clickSelector(page, "button");
+	await waitForSelector(devtools, '[data-testid="elements-tree"]');
+
+	await clickTreeItem(devtools, "ListItem");
+	const owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["Counter", "App"]);
+}
diff --git a/test-e2e/tests/inspect-owner.test.ts b/test-e2e/tests/inspect-owner.test.ts
new file mode 100644
index 0000000..075bfa6
--- /dev/null
+++ b/test-e2e/tests/inspect-owner.test.ts
@@ -0,0 +1,41 @@
+import { clickTreeItem, getOwners, newTestPage } from "../test-utils";
+import { expect } from "chai";
+
+export const description = "Inspect owner information";
+
+export async function run(config: any) {
+	const { devtools } = await newTestPage(config, "update-all");
+
+	await clickTreeItem(devtools, "App");
+
+	let owners = await getOwners(devtools);
+	expect(owners).to.deep.equal([]);
+
+	await clickTreeItem(devtools, "Props");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["App"]);
+
+	await clickTreeItem(devtools, "State");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["App"]);
+
+	await clickTreeItem(devtools, "Context");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["App"]);
+
+	await clickTreeItem(devtools, "Provider");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["Context", "App"]);
+
+	await clickTreeItem(devtools, "Consumer");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["Context", "App"]);
+
+	await clickTreeItem(devtools, "LegacyContext");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["App"]);
+
+	await clickTreeItem(devtools, "LegacyConsumer");
+	owners = await getOwners(devtools);
+	expect(owners).to.deep.equal(["LegacyContext", "App"]);
+}