Repository: preactjs/preact-devtools (ts)

Issue:
Debug: Could not serialize message.
- [x] Check if updating to the latest Preact version resolves the issue

**Describe the bug**
`bigint` properties don't appear in Preact dev tools.

**To Reproduce**
```tsx
import 'preact/debug'
import { render } from "preact"

function Fruit({ apple }) {
  return <div>{apple}</div>
}
function App() {
  return <Fruit apple={3n} />
}
render(<App />, window.root)
```
https://codesandbox.io/s/kind-thompson-3404c9

Steps to reproduce the behavior:
1. Open app in a browser with Preact dev tools extension.
2. Open the Preact dev tools.
3. CLick on the `Fruit` component.
4. Notice that you get an error `Uncaught Error: Could not serialize message.`
5. Notice that the `Fruit` component has no properties according to the dev tools.

**Expected behavior**
Bigints serialize correctly in the dev tools.

----

This is a common problem with `bigint`s as they do not serialize to JSON naively.  You must have a specialize serializer/deserializer that handles them.

Pull request:
Adds support for serializing bigints.
I went with serializing bigints as an object with a type and value, but I'm open to other ideas for how to serialize them so they are JSON compatible.

I don't know why I needed to change the type of `setInCopy`, but the original type definition appears to have been incorrect.

I largely don't understand what most of the properties in `parseProps` are for, so I just tried to copy other parsers in a way that seemed reasonable. In particular, `editable` I set to `!forceReadonly` but this was really just a guess on my part.

I made a few changes to improve the type strictness of `JSONValue`. If these strictness increases are considered undesirable then everything except the addition of `JSONBigint` can be removed without causing problems.

Fixes #453 

Patch:
diff --git a/src/adapter/shared/renderer.ts b/src/adapter/shared/renderer.ts
index cf90f42..9597531 100644
--- a/src/adapter/shared/renderer.ts
+++ b/src/adapter/shared/renderer.ts
@@ -20,6 +20,7 @@ import { createCommit, shouldFilter } from "../shared/traverse";
 import { PreactBindings, SharedVNode } from "../shared/bindings";
 import { inspectVNode } from "./inspectVNode";
 import { logVNode } from "../10/log";
+import { isSerializedBigint } from "../../view/components/sidebar/inspect/serializeProps";
 
 export interface RendererConfig {
 	Fragment: FunctionalComponent;
@@ -313,6 +314,9 @@ export function createRenderer<T extends SharedVNode>(
 		},
 		onUnmount,
 		update(id, type, path, value) {
+			if (isSerializedBigint(value)) {
+				value = BigInt(value.value);
+			}
 			const vnode = getVNodeById(ids, id);
 			if (vnode !== null) {
 				if (bindings.isComponent(vnode)) {
@@ -343,6 +347,9 @@ export function createRenderer<T extends SharedVNode>(
 			}
 		},
 		updateHook(id, index, value) {
+			if (isSerializedBigint(value)) {
+				value = BigInt(value.value);
+			}
 			const vnode = getVNodeById(ids, id);
 			if (vnode !== null && bindings.isComponent(vnode)) {
 				const c = bindings.getComponent(vnode);
@@ -357,6 +364,9 @@ export function createRenderer<T extends SharedVNode>(
 		},
 
 		updateSignal(id, index, value) {
+			if (isSerializedBigint(value)) {
+				value = BigInt(value.value);
+			}
 			const vnode = getVNodeById(ids, id);
 			if (vnode !== null && bindings.isComponent(vnode)) {
 				const c = bindings.getComponent(vnode);
diff --git a/src/adapter/shared/serialize.ts b/src/adapter/shared/serialize.ts
index cc101a6..bce0922 100644
--- a/src/adapter/shared/serialize.ts
+++ b/src/adapter/shared/serialize.ts
@@ -79,6 +79,11 @@ export function jsonify(
 	switch (typeof data) {
 		case "string":
 			return data.length > 300 ? data.slice(300) : data;
+		case "bigint":
+			return {
+				type: "bigint",
+				value: data.toString(10),
+			};
 		case "function": {
 			return {
 				type: "function",
@@ -134,6 +139,7 @@ export function isEditable(x: any) {
 		case "string":
 		case "number":
 		case "boolean":
+		case "bigint":
 			return true;
 		default:
 			return false;
@@ -153,7 +159,7 @@ function clone(value: any) {
 /**
  * Deeply set a property and clone all parent objects/arrays
  */
-export function setInCopy<T = any>(
+export function setInCopy<T extends Record<string, unknown> = any>(
 	obj: T,
 	path: ObjPath,
 	value: any,
diff --git a/src/view/components/DataInput/parseValue.ts b/src/view/components/DataInput/parseValue.ts
index be661d0..c3cbea7 100644
--- a/src/view/components/DataInput/parseValue.ts
+++ b/src/view/components/DataInput/parseValue.ts
@@ -24,6 +24,11 @@ export function parseValue(v: string) {
 		throw new TypeError("Invalid input");
 	} else if (/^[-+.]?\d*(?:[.]?\d*)$/.test(v)) {
 		return Number(v);
+	} else if (/^-?\d+n$/.test(v)) {
+		return {
+			type: "bigint",
+			value: v.slice(0, -1),
+		};
 	} else if (/^\{.*\}$/.test(v) || /^\[.*\]$/.test(v)) {
 		try {
 			return JSON.parse(v);
@@ -65,6 +70,7 @@ export function genPreview(v: any): string {
 			if (v.type === "blob") return "Blob {}";
 			if (v.type === "symbol") return v.name;
 			if (v.type === "html") return v.name;
+			if (v.type === "bigint") return `${v.value}n`;
 		}
 
 		const obj = Object.entries(v).map(x => {
diff --git a/src/view/components/sidebar/inspect/parseProps.ts b/src/view/components/sidebar/inspect/parseProps.ts
index 5438f7c..54fa932 100644
--- a/src/view/components/sidebar/inspect/parseProps.ts
+++ b/src/view/components/sidebar/inspect/parseProps.ts
@@ -16,7 +16,7 @@ export type PropDataType =
 	| "symbol"
 	| "html";
 
-export interface PropData {
+export type PropData = {
 	id: string;
 	name: string;
 	type: PropDataType;
@@ -25,7 +25,7 @@ export interface PropData {
 	depth: number;
 	meta: any;
 	children: string[];
-}
+};
 
 export function parseProps(
 	data: any,
@@ -99,6 +99,22 @@ export function parseProps(
 					children: [],
 					meta: null,
 				});
+			} else if (
+				// Same for bigints
+				maybeCustom &&
+				typeof data.value === "string" &&
+				data.type === "bigint"
+			) {
+				out.set(path, {
+					depth,
+					name,
+					id: path,
+					type: "bigint",
+					editable: !forceReadonly,
+					value: data,
+					children: [],
+					meta: null,
+				});
 			} else if (
 				// Same for vnodes
 				maybeCustom &&
diff --git a/src/view/components/sidebar/inspect/serializeProps.ts b/src/view/components/sidebar/inspect/serializeProps.ts
index e73b81f..3d679fd 100644
--- a/src/view/components/sidebar/inspect/serializeProps.ts
+++ b/src/view/components/sidebar/inspect/serializeProps.ts
@@ -1,15 +1,20 @@
 export interface JSONVNode {
-	type: "vnode";
-	name: string;
+	readonly type: "vnode";
+	readonly name: string;
 }
 
 export interface JSONFunction {
-	type: "function";
-	name: string;
+	readonly type: "function";
+	readonly name: string;
+}
+
+export interface JSONBigint {
+	readonly type: "bigint";
+	readonly value: string;
 }
 
 export interface JSONSet {
-	type: "set";
+	readonly type: "set";
 }
 
 export type JSONValue =
@@ -20,7 +25,9 @@ export type JSONValue =
 	| undefined
 	| JSONVNode
 	| JSONFunction
-	| Record<string, any>;
+	| JSONBigint
+	| { readonly [index: string]: JSONValue }
+	| readonly JSONValue[];
 
 export function serializeProps(value: JSONValue): any {
 	if (Array.isArray(value)) {
@@ -28,15 +35,37 @@ export function serializeProps(value: JSONValue): any {
 	} else if (
 		value !== null &&
 		typeof value === "object" &&
-		Object.keys(value).length === 2
+		Object.keys(value).length === 2 &&
+		"type" in value &&
+		typeof value.type === "string"
 	) {
-		if (typeof value.name === "string") {
+		if ("name" in value && typeof value.name === "string") {
 			if (value.type === "function") {
 				return value.name + "()";
 			} else if (value.type === "vnode") {
 				return `<${value.name} />`;
 			}
+		} else if (
+			"value" in value &&
+			typeof value.value === "string" &&
+			value.type === "bigint"
+		) {
+			return `${value.value}n`;
 		}
 	}
 	return value;
 }
+
+export function isSerializedBigint(value: unknown): value is JSONBigint {
+	return (
+		typeof value === "object" &&
+		value !== null &&
+		Object.keys(value).length === 2 &&
+		"type" in value &&
+		// @ts-ignore can remove after TypeScript 4.9.x: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#unlisted-property-narrowing-with-the-in-operator
+		value.type === "bigint" &&
+		"value" in value &&
+		// @ts-ignore can remove after TypeScript 4.9.x: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#unlisted-property-narrowing-with-the-in-operator
+		typeof value.value === "string"
+	);
+}


Test patch:
diff --git a/src/adapter/shared/serialize.test.tsx b/src/adapter/shared/serialize.test.tsx
index 23edcf6..a6aa686 100644
--- a/src/adapter/shared/serialize.test.tsx
+++ b/src/adapter/shared/serialize.test.tsx
@@ -35,6 +35,16 @@ describe("jsonify", () => {
 			name: "Symbol(foo)",
 		});
 	});
+
+	it("should serialize bigints", () => {
+		const data = { foo: 3n } as const;
+		expect(jsonify(data, () => null, new Set())).to.deep.equal({
+			foo: {
+				type: "bigint",
+				value: "3",
+			},
+		});
+	});
 });
 
 describe("cleanProps", () => {
diff --git a/src/view/components/DataInput/parseValue.test.ts b/src/view/components/DataInput/parseValue.test.ts
index d4072b7..167d7e0 100644
--- a/src/view/components/DataInput/parseValue.test.ts
+++ b/src/view/components/DataInput/parseValue.test.ts
@@ -31,6 +31,28 @@ describe("parseValue", () => {
 		expect(isNaN(parseValue("NaN") as any)).to.equal(true);
 	});
 
+	it("should parse bigints", () => {
+		expect(parseValue("5n")).to.deep.equal({
+			type: "bigint",
+			value: "5",
+		});
+		expect(parseValue("-5n")).to.deep.equal({
+			type: "bigint",
+			value: "-5",
+		});
+		// max int + 1
+		expect(parseValue("18446744073709552000n")).to.deep.equal({
+			type: "bigint",
+			value: "18446744073709552000",
+		});
+		// bigger than Number.MAX_VALUE (max double)
+		const reallyBig = (10n ** 309n).toString(10);
+		expect(parseValue(`${reallyBig}n`)).to.deep.equal({
+			type: "bigint",
+			value: reallyBig,
+		});
+	});
+
 	it("should parse strings", () => {
 		expect(parseValue('"abc"')).to.equal("abc");
 		expect(parseValue('"123"')).to.equal("123");
@@ -65,6 +87,7 @@ describe("genPreview", () => {
 		expect(genPreview("foo")).to.equal('"foo"');
 		expect(genPreview([1, 2, { a: 3 }])).to.equal("[1, 2, {a: 3}]");
 		expect(genPreview({ a: 123, b: [1, 2] })).to.equal("{a: 123, b: [1, 2]}");
+		expect(genPreview({ type: "bigint", value: "3" })).to.equal("3n");
 
 		expect(genPreview({ type: "symbol", name: "Symbol(foo)" })).to.equal(
 			"Symbol(foo)",
diff --git a/src/view/components/sidebar/inspect/parseProps.test.ts b/src/view/components/sidebar/inspect/parseProps.test.ts
index 2fc231b..69e2730 100644
--- a/src/view/components/sidebar/inspect/parseProps.test.ts
+++ b/src/view/components/sidebar/inspect/parseProps.test.ts
@@ -87,6 +87,27 @@ describe("parseProps", () => {
 		]);
 	});
 
+	it("should parse bigints", () => {
+		const big = {
+			type: "bigint",
+			value: "3",
+		};
+		const tree = parseProps(big, "foo", 2);
+
+		expect(serialize(tree)).to.deep.equal([
+			{
+				editable: true,
+				depth: 0,
+				id: "foo",
+				name: "foo",
+				type: "bigint",
+				value: big,
+				children: [],
+				meta: null,
+			},
+		]);
+	});
+
 	it("should parse functions", () => {
 		const fn = {
 			type: "function",
diff --git a/src/view/components/sidebar/inspect/serializeProps.test.ts b/src/view/components/sidebar/inspect/serializeProps.test.ts
index b3b4b17..269e0a8 100644
--- a/src/view/components/sidebar/inspect/serializeProps.test.ts
+++ b/src/view/components/sidebar/inspect/serializeProps.test.ts
@@ -26,4 +26,8 @@ describe("serializeProps", () => {
 	it("should serialize functions", () => {
 		expect(serializeProps({ type: "function", name: "foo" })).to.equal("foo()");
 	});
+
+	it("should serialize bigints", () => {
+		expect(serializeProps({ type: "bigint", value: "3" })).to.equal("3n");
+	});
 });