Repository: preactjs/preact-devtools
Pull request #252 (2020-09-17 08:48:46)

Display key in sidebar
This PR adds a new panel to the Elements sidebar to show the key of the selected `vnode`.

![Screenshot from 2020-09-17 10-48-04](https://user-images.githubusercontent.com/1062408/93447971-55d48380-f8d3-11ea-8aa3-f4b8a399f9ba.png)



Patch:
diff --git a/.changeset/neat-dragons-swim.md b/.changeset/neat-dragons-swim.md
new file mode 100644
index 0000000..b7f70d8
--- /dev/null
+++ b/.changeset/neat-dragons-swim.md
@@ -0,0 +1,5 @@
+---
+"preact-devtools": patch
+---
+
+Display Component key in the sidebar
diff --git a/src/adapter/10/renderer/inspectVNode.ts b/src/adapter/10/renderer/inspectVNode.ts
index 7a388f4..c05dc66 100644
--- a/src/adapter/10/renderer/inspectVNode.ts
+++ b/src/adapter/10/renderer/inspectVNode.ts
@@ -5,6 +5,7 @@ import { ID } from "../../../view/store/types";
 import { IdMappingState, getVNodeById } from "../IdMapper";
 import { Options } from "preact";
 import { inspectHooks } from "./hooks";
+import { InspectData } from "../../adapter/adapter";
 
 /**
  * Serialize all properties/attributes of a `VNode` like `props`, `context`,
@@ -18,7 +19,7 @@ export function inspectVNode(
 	options: Options,
 	id: ID,
 	supportsHooks: boolean,
-) {
+): InspectData | null {
 	const vnode = getVNodeById(ids, id);
 	if (!vnode) return null;
 
@@ -38,6 +39,7 @@ export function inspectVNode(
 
 	return {
 		context,
+		key: vnode.key || null,
 		hooks: supportsHooks ? hooks : !supportsHooks && hasHooks ? [] : null,
 		id,
 		name: getDisplayName(vnode, config),
diff --git a/src/adapter/adapter/adapter.ts b/src/adapter/adapter/adapter.ts
index 6fb84b9..60b3cfb 100644
--- a/src/adapter/adapter/adapter.ts
+++ b/src/adapter/adapter/adapter.ts
@@ -28,6 +28,7 @@ export interface InspectData {
 	id: ID;
 	name: string;
 	type: any;
+	key: string | null;
 	context: Record<string, any> | null;
 	hooks: PropData[] | null;
 	props: Record<string, any> | null;
diff --git a/src/view/components/sidebar/KeyPanel.css b/src/view/components/sidebar/KeyPanel.css
new file mode 100644
index 0000000..b95474b
--- /dev/null
+++ b/src/view/components/sidebar/KeyPanel.css
@@ -0,0 +1,11 @@
+.key {
+	color: var(--color-key-value);
+	font-family: var(--font-monospace);
+	font-size: var(--font-small);
+	padding-left: 1.25rem;
+	display: inline-block;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	width: 100%;
+}
diff --git a/src/view/components/sidebar/KeyPanel.tsx b/src/view/components/sidebar/KeyPanel.tsx
new file mode 100644
index 0000000..b32cfba
--- /dev/null
+++ b/src/view/components/sidebar/KeyPanel.tsx
@@ -0,0 +1,18 @@
+import { h } from "preact";
+import { SidebarPanel } from "./SidebarPanel";
+import s from "./KeyPanel.css";
+
+export interface Props {
+	onCopy: () => void;
+	value: string;
+}
+
+export function KeyPanel(props: Props) {
+	return (
+		<SidebarPanel title="Key" testId="key-panel" onCopy={props.onCopy}>
+			<span class={s.key} data-testid="vnode-key">
+				{props.value}
+			</span>
+		</SidebarPanel>
+	);
+}
diff --git a/src/view/components/sidebar/Sidebar.tsx b/src/view/components/sidebar/Sidebar.tsx
index 188e8b8..8aef211 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 { KeyPanel } from "./KeyPanel";
 
 export function Sidebar() {
 	const store = useStore();
@@ -14,6 +15,12 @@ export function Sidebar() {
 
 	return (
 		<Fragment>
+			{inspect && inspect.key !== null && (
+				<KeyPanel
+					value={inspect.key}
+					onCopy={() => emit("copy", inspect.key!)}
+				/>
+			)}
 			<PropsPanel
 				label="Props"
 				items={propData.items}


Test patch:
diff --git a/src/adapter/events/events.test.ts b/src/adapter/events/events.test.ts
index d1aa377..043b8b8 100644
--- a/src/adapter/events/events.test.ts
+++ b/src/adapter/events/events.test.ts
@@ -223,6 +223,7 @@ describe("applyEvent", () => {
 
 		store.inspectData.$ = {
 			id: 2,
+			key: null,
 			context: null,
 			hooks: null,
 			name: "Foo",
@@ -247,6 +248,7 @@ describe("applyEvent", () => {
 
 		store.inspectData.$ = {
 			id: 2,
+			key: null,
 			context: null,
 			hooks: null,
 			name: "Foo",
@@ -262,6 +264,7 @@ describe("applyEvent", () => {
 		applyEvent(store, "inspect-result", {
 			id: 42,
 			name: "foo",
+			key: null,
 			type: "string",
 			context: null,
 			hooks: null,
diff --git a/test-e2e/fixtures/index.html b/test-e2e/fixtures/index.html
index 395eb9b..d0bf765 100644
--- a/test-e2e/fixtures/index.html
+++ b/test-e2e/fixtures/index.html
@@ -51,6 +51,7 @@
 				{ name: "truncate" },
 				{ name: "update-all" },
 				{ name: "useCallback" },
+				{ name: "keys" },
 			];
 
 			const list = document.querySelector("#list");
diff --git a/test-e2e/fixtures/keys.js b/test-e2e/fixtures/keys.js
new file mode 100644
index 0000000..73d6318
--- /dev/null
+++ b/test-e2e/fixtures/keys.js
@@ -0,0 +1,22 @@
+const { h, render } = preact;
+
+function ListItem(props) {
+	return html`<li>${props.children}</li>`;
+}
+function NoKey(props) {
+	return html`<li>${props.children}</li>`;
+}
+
+function Keys() {
+	return html`
+		<div style="padding: 2rem;">
+			<ul>
+				<${ListItem} key="ABC">abc<//>
+				<${ListItem} key="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789">long key<//>
+				<${NoKey}>no key<//>
+			</ul>
+		</div>
+	`;
+}
+
+render(h(Keys), document.getElementById("app"));
diff --git a/test-e2e/tests/inspect-key.test.ts b/test-e2e/tests/inspect-key.test.ts
new file mode 100644
index 0000000..d454074
--- /dev/null
+++ b/test-e2e/tests/inspect-key.test.ts
@@ -0,0 +1,39 @@
+import { newTestPage, click } from "../test-utils";
+import { expect } from "chai";
+import {
+	assertNotTestId,
+	clickNestedText,
+	getText,
+	waitForTestId,
+} from "pentf/browser_utils";
+import { assertEventually } from "pentf/assert_utils";
+
+export const description = "Show vnode key in the sidebar";
+
+export async function run(config: any) {
+	const { page, devtools } = await newTestPage(config, "keys");
+	const origin = await page.evaluate(() => location.origin);
+	await page.browserContext().overridePermissions(origin, ["clipboard-read"]);
+
+	await clickNestedText(devtools, /ABC$/);
+	await waitForTestId(devtools, "key-panel");
+
+	const target = '[data-testid="vnode-key"]';
+	expect(await getText(devtools, target)).to.equal("ABC");
+
+	const copy = '[data-testid="key-panel"] button[title="Copy Key"]';
+	await click(devtools, copy);
+
+	const clipboard = await page.evaluate(() => navigator.clipboard.readText());
+	expect(JSON.parse(clipboard)).to.equal("ABC");
+
+	// Check that the keypanel is not present for keyless components
+	await clickNestedText(devtools, "NoKey");
+	await assertEventually(
+		async () => {
+			await assertNotTestId(devtools, "key-panel");
+			return true;
+		},
+		{ timeout: 2000, crashOnError: false },
+	);
+}