Repository: preactjs/preact-devtools (ts)

Issue:
Display content of ES6 Map props
Hi!

I'm a relative beginner in JS and SPA development, and I was surprised to find that the Preact Devtools extension (in Firefox at least) doesn't show the content for props of type ES6 Map, but only the names of its methods (`clear`, `set`, `delete`, `add`). Here's what I see (look at `session.document.windows.objects`, for example):

![image](https://user-images.githubusercontent.com/7526491/118886432-39bd3200-b8f9-11eb-88a3-18d8eae9e3bc.png)

Is this a bug, or missing feature? Is it planned? If it isn't, what do you think of it? Could you guide me in implementing it?

Thanks!

Pull request description:
Add support for inspecting Map and Set objects
Fixes #300

Patch:
diff --git a/src/adapter/10/utils.ts b/src/adapter/10/utils.ts
index 380bca5..b08da42 100644
--- a/src/adapter/10/utils.ts
+++ b/src/adapter/10/utils.ts
@@ -62,6 +62,25 @@ export function jsonify(
 					type: "blob",
 					name: "Blob",
 				};
+			} else if (data instanceof Set) {
+				return {
+					type: "set",
+					name: "Set",
+					entries: Array.from(data.values()).map(item =>
+						jsonify(item, getVNode, seen),
+					),
+				};
+			} else if (data instanceof Map) {
+				return {
+					type: "map",
+					name: "Map",
+					entries: Array.from(data.entries()).map(entry => {
+						return [
+							jsonify(entry[0], getVNode, seen),
+							jsonify(entry[1], getVNode, seen),
+						];
+					}),
+				};
 			}
 			const out = { ...data };
 			Object.keys(out).forEach(key => {
@@ -116,6 +135,16 @@ export function cleanContext(context: Record<string, any>) {
 	return res;
 }
 
+function clone(value: any) {
+	if (Array.isArray(value)) return value.slice();
+	if (value !== null && typeof value === "object") {
+		if (value instanceof Set) return new Set(value);
+		if (value instanceof Map) return new Map(value);
+		return { ...value };
+	}
+	return value;
+}
+
 /**
  * Deeply set a property and clone all parent objects/arrays
  */
@@ -127,9 +156,25 @@ export function setInCopy<T = any>(
 ): T {
 	if (idx >= path.length) return value;
 
-	const updated = Array.isArray(obj) ? obj.slice() : { ...obj };
-	const key = path[idx];
-	(updated as any)[key] = setInCopy((obj as any)[key], path, value, idx + 1);
+	const updated = clone(obj);
+	if (obj instanceof Set) {
+		const oldValue = Array.from(obj)[+path[idx]];
+		updated.delete(oldValue);
+		updated.add(setInCopy(oldValue, path, value, idx + 1));
+	} else if (obj instanceof Map) {
+		const oldEntry = Array.from(obj)[+path[idx]];
+		const isKey = +path[idx + 1] === 0;
+		if (isKey) {
+			updated.delete(oldEntry[0]);
+			updated.set(setInCopy(oldEntry[0], path, value, idx + 2), oldEntry[1]);
+		} else {
+			updated.delete(oldEntry[0]);
+			updated.set(oldEntry[0], setInCopy(oldEntry[1], path, value, idx + 2));
+		}
+	} else {
+		const key = path[idx];
+		(updated as any)[key] = setInCopy((obj as any)[key], path, value, idx + 1);
+	}
 	return updated as any;
 }
 
diff --git a/src/view/components/DataInput/index.tsx b/src/view/components/DataInput/index.tsx
index a0eece8..f5d9764 100644
--- a/src/view/components/DataInput/index.tsx
+++ b/src/view/components/DataInput/index.tsx
@@ -41,6 +41,8 @@ export function DataInput({
 			const parsed = parseValue(value);
 			if (parsed === null) return "null";
 			else if (Array.isArray(parsed)) return "array";
+			else if (parsed.type === "map") return "map";
+			else if (parsed.type === "set") return "set";
 			return typeof parsed;
 		} catch (err) {
 			return "undefined";
diff --git a/src/view/components/DataInput/parseValue.ts b/src/view/components/DataInput/parseValue.ts
index e578ce0..b548771 100644
--- a/src/view/components/DataInput/parseValue.ts
+++ b/src/view/components/DataInput/parseValue.ts
@@ -45,14 +45,18 @@ function truncate(s: string) {
 }
 
 export function genPreview(v: any): string {
-	if (Array.isArray(v)) {
-		return `[${v.map(x => genPreview(x)).join(", ")}]`;
-	}
 	if (v !== null && typeof v === "object") {
+		if (v.type === "set") {
+			return `Set(${v.entries.length}) ${truncate(genPreview(v.entries))})`;
+		} else if (v.type === "map") {
+			return `Map(${v.entries.length}) ${truncate(genPreview(v.entries))})`;
+		}
+
+		if (Array.isArray(v)) {
+			return `[${v.map(x => genPreview(x)).join(", ")}]`;
+		}
 		if (Object.keys(v).length === 2) {
 			if (v.type === "vnode") return `<${truncate(v.name)} />`;
-			if (v.type === "set") return `Set<${truncate(v.name)}>`;
-			if (v.type === "map") return `Map<${truncate(v.name)}>`;
 			if (v.type === "function") {
 				return `ƒ ${v.name === "anonymous" ? "" : truncate(v.name)}()`;
 			}
diff --git a/src/view/components/sidebar/inspect/parseProps.ts b/src/view/components/sidebar/inspect/parseProps.ts
index aaaa847..4581902 100644
--- a/src/view/components/sidebar/inspect/parseProps.ts
+++ b/src/view/components/sidebar/inspect/parseProps.ts
@@ -64,18 +64,6 @@ export function parseProps(
 			children.push(childPath);
 			parseProps(item, childPath, limit, depth + 1, "" + i, out);
 		});
-	} else if (data instanceof Set) {
-		// TODO: We're dealing with serialized data here, not a Set object
-		out.set(path, {
-			depth,
-			name,
-			id: path,
-			type: "set",
-			editable: false,
-			value: "Set",
-			children: [],
-			meta: null,
-		});
 	} else if (typeof data === "object") {
 		if (data === null) {
 			out.set(path, {
@@ -89,7 +77,9 @@ export function parseProps(
 				meta: null,
 			});
 		} else {
-			const maybeCustom = Object.keys(data).length === 2;
+			const keys = Object.keys(data);
+			const maybeCustom = keys.length === 2;
+			const maybeCollection = keys.length === 3;
 			// Functions are encoded as objects
 			if (
 				maybeCustom &&
@@ -123,37 +113,49 @@ export function parseProps(
 					meta: null,
 				});
 			} else if (
-				// Same for Set
-				maybeCustom &&
-				typeof data.name === "string" &&
-				data.type === "set"
-			) {
-				out.set(path, {
-					depth,
-					name,
-					id: path,
-					type: "set",
-					editable: false,
-					value: data,
-					children: [],
-					meta: null,
-				});
-			} else if (
-				// Same for Map
-				maybeCustom &&
-				typeof data.name === "string" &&
-				data.type === "map"
+				// Same for Set + Map
+				maybeCollection &&
+				typeof data.name === "string"
 			) {
-				out.set(path, {
-					depth,
-					name,
-					id: path,
-					type: "map",
-					editable: false,
-					value: data,
-					children: [],
-					meta: null,
-				});
+				if (data.type === "set") {
+					const children: any[] = [];
+					const node: PropData = {
+						depth,
+						name,
+						id: path,
+						type: "set",
+						editable: false,
+						value: data,
+						children,
+						meta: null,
+					};
+
+					(data.entries as any[]).forEach((item, i) => {
+						const childPath = `${path}.${i}`;
+						children.push(childPath);
+						parseProps(item, childPath, limit, depth + 1, "" + i, out);
+					});
+					out.set(path, node);
+				} else if (data.type === "map") {
+					const children: any[] = [];
+					const node: PropData = {
+						depth,
+						name,
+						id: path,
+						type: "map",
+						editable: false,
+						value: data,
+						children,
+						meta: null,
+					};
+
+					(data.entries as any[]).forEach((item, i) => {
+						const childPath = `${path}.${i}`;
+						children.push(childPath);
+						parseProps(item, childPath, limit, depth + 1, "" + i, out);
+					});
+					out.set(path, node);
+				}
 			} else if (
 				// Same for Blobs
 				maybeCustom &&


Test patch:
diff --git a/src/adapter/10/utils.test.tsx b/src/adapter/10/utils.test.tsx
index 11b612c..7b79085 100644
--- a/src/adapter/10/utils.test.tsx
+++ b/src/adapter/10/utils.test.tsx
@@ -147,4 +147,34 @@ describe("setInCopy", () => {
 		expect(res).not.equal(obj);
 		expect(res.foo).not.equal(obj.foo);
 	});
+
+	it("should update value in Set", () => {
+		const obj = { foo: new Set([{ bar: 1 }]) };
+		const res = setInCopy(obj, ["foo", "bar"], 2);
+		expect(Array.from(res.foo.values())).to.deep.equal([{ bar: 1 }, 2]);
+		expect(res).not.equal(obj);
+		expect(res.foo).not.equal(obj.foo);
+	});
+
+	describe("Map", () => {
+		it("should update value", () => {
+			const obj = { foo: new Map([[{ bar: "foo" }, 1]]) };
+			const res = setInCopy(obj, ["foo", "0", "1"], 2);
+			expect(res).to.deep.equal({
+				foo: new Map([[{ bar: "foo" }, 2]]),
+			});
+			expect(res).not.equal(obj);
+			expect(res.foo).not.equal(obj.foo);
+		});
+
+		it("should update key", () => {
+			const obj = { foo: new Map([[{ bar: "foo" }, 1]]) };
+			const res = setInCopy(obj, ["foo", "0", "0", "bar"], "bar");
+			expect(Array.from(res.foo.entries())).to.deep.equal([
+				[{ bar: "bar" }, 1],
+			]);
+			expect(res).not.equal(obj);
+			expect(res.foo).not.equal(obj.foo);
+		});
+	});
 });
diff --git a/test-e2e/fixtures/apps/inspect-map-set.jsx b/test-e2e/fixtures/apps/inspect-map-set.jsx
new file mode 100644
index 0000000..5ffc8b2
--- /dev/null
+++ b/test-e2e/fixtures/apps/inspect-map-set.jsx
@@ -0,0 +1,25 @@
+import { h, render } from "preact";
+
+function App(props) {
+	return (
+		<div>
+			<p>
+				set:{" "}
+				<pre id="json-set">
+					{JSON.stringify(Array.from(props.set.values()), null, 2)}
+				</pre>
+			</p>
+			<p>
+				map:{" "}
+				<pre id="json-map">
+					{JSON.stringify(Array.from(props.map.entries()), null, 2)}
+				</pre>
+			</p>
+		</div>
+	);
+}
+
+render(
+	<App set={new Set([{ foo: 123 }])} map={new Map([[{ foo: 123 }, 123]])} />,
+	document.getElementById("app"),
+);
diff --git a/test-e2e/tests/inspect-map-set.test.ts b/test-e2e/tests/inspect-map-set.test.ts
new file mode 100644
index 0000000..1cae7fb
--- /dev/null
+++ b/test-e2e/tests/inspect-map-set.test.ts
@@ -0,0 +1,79 @@
+import { newTestPage } from "../test-utils";
+import { expect } from "chai";
+import {
+	clickNestedText,
+	clickSelector,
+	getText,
+	typeSelector,
+	waitForTestId,
+} from "pentf/browser_utils";
+import { waitForPass } from "pentf/assert_utils";
+
+export const description = "Inspect Map and Set objects";
+
+export async function run(config: any) {
+	const { page, devtools } = await newTestPage(config, "inspect-map-set");
+
+	await clickNestedText(devtools, /App$/);
+	await waitForTestId(devtools, "Props");
+
+	await waitForPass(async () => {
+		const count = await devtools.$$eval(
+			'[data-testid="props-row"]',
+			els => els.length,
+		);
+		expect(count).to.equal(2);
+	});
+
+	await waitForPass(async () => {
+		const values = await devtools.$$eval('[data-testid="prop-value"]', els =>
+			Array.from(els).map(el => el.textContent),
+		);
+		expect(values).to.deep.equal([
+			"Set(1) [{foo: 123}])",
+			"Map(1) [[{foo: 123}, 123]])",
+		]);
+	});
+
+	// Edit set
+	await clickSelector(devtools, '[data-testid="prop-name"][data-type="set"]');
+	await clickSelector(devtools, '[data-testid="props-row"][data-depth="2"]');
+	await typeSelector(
+		devtools,
+		'[data-testid="props-row"][data-depth="3"] input',
+		"12345",
+	);
+	await devtools.keyboard.press("Enter");
+	await waitForPass(async () => {
+		const text = await getText(page, "#json-set");
+		expect(text).to.equal(JSON.stringify([{ foo: 12312345 }], null, 2));
+	});
+
+	// Close set
+	await clickSelector(devtools, '[data-testid="prop-name"][data-type="set"]');
+
+	// Edit map value
+	await clickSelector(devtools, '[data-type="map"]');
+	await clickSelector(devtools, '[data-testid="props-row"][data-depth="2"]');
+	await typeSelector(
+		devtools,
+		'[data-testid="props-row"][data-depth="3"] input',
+		"12345",
+	);
+	await devtools.keyboard.press("Enter");
+	await waitForPass(async () => {
+		const text = await getText(page, "#json-map");
+		expect(text).to.equal(JSON.stringify([[{ foo: 123 }, 12312345]], null, 2));
+	});
+
+	// Edit map key
+	await clickSelector(devtools, '[data-depth="3"] [data-testid="prop-name"]');
+	await typeSelector(devtools, '[name="root.map.0.0.foo"]', "111");
+	await devtools.keyboard.press("Enter");
+	await waitForPass(async () => {
+		const text = await getText(page, "#json-map");
+		expect(text).to.equal(
+			JSON.stringify([[{ foo: 123111 }, 12312345]], null, 2),
+		);
+	});
+}