Repository: preactjs/preact-devtools
Pull request #266 (2020-09-22 22:04:54)

Add support for suspending components
This PR adds a new button in the sidebar action area that can be used to suspend trees. On click it will search upwards for the nearest Suspense component and toggle the suspending state.

![Screenshot from 2020-09-22 23-53-00](https://user-images.githubusercontent.com/1062408/93941529-f5a86c00-fd2e-11ea-9293-56526ccae106.png)


changeset-bot[bot]: ###  💥  No Changeset

Latest commit: a075f2ad0868de2f7291db661ff9621b055a1640

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, you need to add a changeset.**



[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/preactjs/preact-devtools/new/suspend-toggle?filename=.changeset/few-sloths-change.md&value=---%0A%22%40fake-scope%2Ffake-pkg%22%3A%20patch%0A---%0A%0AAdd%20support%20for%20suspending%20components%0A)



Patch:
diff --git a/.changeset/stale-clouds-rescue.md b/.changeset/stale-clouds-rescue.md
new file mode 100644
index 0000000..a5e7d3c
--- /dev/null
+++ b/.changeset/stale-clouds-rescue.md
@@ -0,0 +1,5 @@
+---
+"preact-devtools": patch
+---
+
+Add support for suspending Suspense components
diff --git a/src/adapter/10/renderer.ts b/src/adapter/10/renderer.ts
index af32dad..c00253b 100644
--- a/src/adapter/10/renderer.ts
+++ b/src/adapter/10/renderer.ts
@@ -19,6 +19,7 @@ import {
 	hasDom,
 	setNextState,
 	getHookState,
+	createSuspenseState,
 } from "./vnode";
 import { shouldFilter } from "./filter";
 import { ID, DevNodeType } from "../../view/store/types";
@@ -588,6 +589,9 @@ export function createRenderer(
 		removeVNodeId(ids, vnode);
 	}
 
+	const inspect = (id: ID) =>
+		inspectVNode(ids, config, options, id, supports.hooks);
+
 	return {
 		// TODO: Deprecate
 		// eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -636,7 +640,7 @@ export function createRenderer(
 			return "Unknown";
 		},
 		log: (id, children) => logVNode(ids, config, id, children),
-		inspect: id => inspectVNode(ids, config, options, id, supports.hooks),
+		inspect,
 		findDomForVNode(id) {
 			const vnode = getVNodeById(ids, id);
 			if (!vnode) return null;
@@ -807,5 +811,37 @@ export function createRenderer(
 				}
 			}
 		},
+
+		suspend(id, active) {
+			let vnode = getVNodeById(ids, id);
+			while (vnode !== null) {
+				if (isSuspenseVNode(vnode)) {
+					const c = getComponent(vnode);
+					if (c) {
+						c.setState(createSuspenseState(vnode, active));
+					}
+
+					// Get nearest non-filtered vnode
+					let nearest: VNode | null = vnode;
+					while (nearest && shouldFilter(nearest, filters, config)) {
+						nearest = getVNodeParent(nearest);
+					}
+
+					if (nearest && hasVNodeId(ids, nearest)) {
+						const nearestId = getVNodeId(ids, nearest);
+						if (id !== nearestId) {
+							const inspectData = inspect(nearestId);
+							if (inspectData) {
+								inspectData.suspended = active;
+								port.send("inspect-result", inspectData);
+							}
+						}
+					}
+					break;
+				}
+
+				vnode = getVNodeParent(vnode);
+			}
+		},
 	};
 }
diff --git a/src/adapter/10/renderer/inspectVNode.ts b/src/adapter/10/renderer/inspectVNode.ts
index c05dc66..83a8004 100644
--- a/src/adapter/10/renderer/inspectVNode.ts
+++ b/src/adapter/10/renderer/inspectVNode.ts
@@ -1,9 +1,16 @@
-import { getComponent, getComponentHooks, getDisplayName } from "../vnode";
+import {
+	getComponent,
+	getComponentHooks,
+	getDisplayName,
+	getSuspenseStateKey,
+	getVNodeParent,
+	isSuspenseVNode,
+} from "../vnode";
 import { serialize, cleanContext, cleanProps } from "../utils";
 import { RendererConfig10, getDevtoolsType } from "../renderer";
 import { ID } from "../../../view/store/types";
 import { IdMappingState, getVNodeById } from "../IdMapper";
-import { Options } from "preact";
+import { Options, VNode } from "preact";
 import { inspectHooks } from "./hooks";
 import { InspectData } from "../../adapter/adapter";
 
@@ -37,8 +44,29 @@ export function inspectVNode(
 		vnode.type !== null ? serialize(config, cleanProps(vnode.props)) : null;
 	const state = hasState ? serialize(config, c!.state) : null;
 
+	let suspended = false;
+	let canSuspend = false;
+	let item: VNode | null = vnode;
+	while (item) {
+		if (isSuspenseVNode(item)) {
+			canSuspend = true;
+
+			const c = getComponent(item);
+			if (c) {
+				const key = getSuspenseStateKey(c);
+				if (key) {
+					suspended = !!(c as any)._nextState[key];
+				}
+			}
+			break;
+		}
+
+		item = getVNodeParent(item);
+	}
+
 	return {
 		context,
+		canSuspend,
 		key: vnode.key || null,
 		hooks: supportsHooks ? hooks : !supportsHooks && hasHooks ? [] : null,
 		id,
@@ -47,5 +75,6 @@ export function inspectVNode(
 		state,
 		// TODO: We're not using this information anywhere yet
 		type: getDevtoolsType(vnode),
+		suspended,
 	};
 }
diff --git a/src/adapter/10/vnode.ts b/src/adapter/10/vnode.ts
index 29de709..0a9ca07 100644
--- a/src/adapter/10/vnode.ts
+++ b/src/adapter/10/vnode.ts
@@ -217,3 +217,30 @@ export function getNextState<S>(c: Component): S {
 export function setNextState<S>(c: Component, value: S): S {
 	return ((c as IComponent)._nextState = (c as any).__s = value);
 }
+
+export function getSuspenseStateKey(c: Component<any, any>) {
+	if ("_suspended" in c.state) {
+		return "_suspended";
+	} else if ("__e" in c.state) {
+		return "__e";
+	}
+
+	// This is a bit whacky, but property name mangling is unsafe in
+	// Preact <10.4.9
+	const keys = Object.keys(c.state);
+	if (keys.length > 0) {
+		return keys[0];
+	}
+
+	return null;
+}
+
+export function createSuspenseState(vnode: VNode, suspended: boolean) {
+	const c = getComponent(vnode) as Component<any, any>;
+	const key = getSuspenseStateKey(c);
+	if (c && key) {
+		return { [key]: suspended };
+	}
+
+	return {};
+}
diff --git a/src/adapter/MultiRenderer.ts b/src/adapter/MultiRenderer.ts
index 9cc838c..911726f 100644
--- a/src/adapter/MultiRenderer.ts
+++ b/src/adapter/MultiRenderer.ts
@@ -21,6 +21,9 @@ export function createMultiRenderer(
 	renderers: Map<number, Renderer>,
 ): Renderer {
 	return {
+		suspend(id, active) {
+			renderers.forEach(r => r.suspend && r.suspend(id, active));
+		},
 		refresh() {
 			renderers.forEach(r => r.refresh && r.refresh());
 		},
diff --git a/src/adapter/adapter/adapter.ts b/src/adapter/adapter/adapter.ts
index 60b3cfb..8c137b7 100644
--- a/src/adapter/adapter/adapter.ts
+++ b/src/adapter/adapter/adapter.ts
@@ -33,6 +33,9 @@ export interface InspectData {
 	hooks: PropData[] | null;
 	props: Record<string, any> | null;
 	state: Record<string, any> | null;
+	canSuspend: boolean;
+	/** Only Suspense components have this */
+	suspended: boolean;
 }
 
 export function createAdapter(port: PortPageHook, renderer: Renderer) {
@@ -200,4 +203,10 @@ export function createAdapter(port: PortPageHook, renderer: Renderer) {
 			hook.$type = null;
 		}
 	});
+
+	listen("suspend", data => {
+		if (renderer.suspend) {
+			renderer.suspend(data.id, data.active);
+		}
+	});
 }
diff --git a/src/adapter/hook.ts b/src/adapter/hook.ts
index e85cbf4..4cd7fc6 100644
--- a/src/adapter/hook.ts
+++ b/src/adapter/hook.ts
@@ -53,6 +53,7 @@ export interface DevtoolEvents {
 	init: null;
 	refresh: null;
 	disconnect: null;
+	suspend: { id: ID; active: boolean };
 }
 export type EmitFn = <K extends keyof DevtoolEvents>(
 	name: K,
diff --git a/src/adapter/renderer.ts b/src/adapter/renderer.ts
index a6b851e..117084d 100644
--- a/src/adapter/renderer.ts
+++ b/src/adapter/renderer.ts
@@ -36,4 +36,7 @@ export interface Renderer {
 
 	// Hooks
 	updateHook?(id: ID, index: number, value: any): void; // V3
+
+	// Component actions
+	suspend?(id: ID, active: boolean): void; // V4
 }
diff --git a/src/view/components/ComponentName/index.tsx b/src/view/components/ComponentName/index.tsx
index 9bd0beb..4b8b90c 100644
--- a/src/view/components/ComponentName/index.tsx
+++ b/src/view/components/ComponentName/index.tsx
@@ -3,7 +3,7 @@ import s from "./ComponentName.css";
 
 export function ComponentName(props: { children: any }) {
 	return (
-		<span class={s.title}>
+		<span class={s.title} data-testid="inspect-component-name">
 			{props.children ? (
 				<Fragment>
 					<span class={s.nameCh}>&lt;</span>
diff --git a/src/view/components/icons.tsx b/src/view/components/icons.tsx
index dc5ff08..a66c04d 100644
--- a/src/view/components/icons.tsx
+++ b/src/view/components/icons.tsx
@@ -299,3 +299,16 @@ export function CodeIcon({ size = "s" }: Props) {
 		</Fragment>,
 	);
 }
+
+export function SuspendIcon({ size = "s" }: Props) {
+	return createSvgIcon(
+		size,
+		<Fragment>
+			<path d="M0 0h24v24H0z" fill="none" />
+			<path
+				d="M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42C16.07 4.74 14.12 4 12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9 9-4.03 9-9c0-2.12-.74-4.07-1.97-5.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"
+				fill="currentColor"
+			/>
+		</Fragment>,
+	);
+}
diff --git a/src/view/components/sidebar/SidebarActions.tsx b/src/view/components/sidebar/SidebarActions.tsx
index 067bd59..27b59f6 100644
--- a/src/view/components/sidebar/SidebarActions.tsx
+++ b/src/view/components/sidebar/SidebarActions.tsx
@@ -2,7 +2,7 @@ import { h, Fragment } from "preact";
 import s from "./Sidebar.css";
 import { Actions } from "../Actions";
 import { IconBtn } from "../IconBtn";
-import { BugIcon, InspectNativeIcon, CodeIcon } from "../icons";
+import { BugIcon, InspectNativeIcon, CodeIcon, SuspendIcon } from "../icons";
 import { useStore, useEmitter, useObserver } from "../../store/react-bindings";
 import { useCallback } from "preact/hooks";
 import { ComponentName } from "../ComponentName";
@@ -31,6 +31,25 @@ export function SidebarActions() {
 		node.type !== DevNodeType.Group &&
 		node.type !== DevNodeType.Element;
 
+	const suspense = useObserver(() => {
+		const state = {
+			canSuspend: false,
+			suspended: false,
+		};
+
+		if (store.inspectData.$) {
+			state.canSuspend = store.inspectData.$.canSuspend;
+			state.suspended = store.inspectData.$.suspended;
+		}
+
+		return state;
+	});
+	const onSuspend = useCallback(() => {
+		if (node) {
+			emit("suspend", { id: node.id, active: !suspense.suspended });
+		}
+	}, [node, suspense]);
+
 	return (
 		<Actions class={s.actions}>
 			<ComponentName>{node && node.name}</ComponentName>
@@ -38,6 +57,16 @@ export function SidebarActions() {
 			<div class={s.iconActions}>
 				{node && (
 					<Fragment>
+						{suspense.canSuspend && (
+							<IconBtn
+								title="Suspend Tree"
+								testId="suspend-action"
+								active={suspense.suspended}
+								onClick={onSuspend}
+							>
+								<SuspendIcon />
+							</IconBtn>
+						)}
 						<IconBtn
 							title="Show matching DOM element"
 							onClick={inspectHostNode}


Test patch:
diff --git a/src/adapter/events/events.test.ts b/src/adapter/events/events.test.ts
index 043b8b8..b2694c4 100644
--- a/src/adapter/events/events.test.ts
+++ b/src/adapter/events/events.test.ts
@@ -230,6 +230,8 @@ describe("applyEvent", () => {
 			props: null,
 			state: null,
 			type: "asd",
+			canSuspend: false,
+			suspended: false,
 		};
 
 		const data = fromSnapshot([
@@ -255,6 +257,8 @@ describe("applyEvent", () => {
 			props: null,
 			state: null,
 			type: "asd",
+			canSuspend: false,
+			suspended: false,
 		};
 
 		store.sidebar.props.uncollapsed.$ = ["a", "b", "c"];
diff --git a/test-e2e/tests/suspense-toggle.test.ts b/test-e2e/tests/suspense-toggle.test.ts
new file mode 100644
index 0000000..53c1970
--- /dev/null
+++ b/test-e2e/tests/suspense-toggle.test.ts
@@ -0,0 +1,69 @@
+import { newTestPage, getTreeViewItemNames } from "../test-utils";
+import { expect } from "chai";
+import {
+	assertNotTestId,
+	clickSelector,
+	clickTestId,
+	getAttribute,
+	getText,
+} from "pentf/browser_utils";
+import { assertEventually } from "pentf/assert_utils";
+
+export const description = "Display Suspense in tree view";
+export async function run(config: any) {
+	const { devtools } = await newTestPage(config, "suspense");
+
+	await devtools.waitForSelector(
+		'[data-testid="tree-item"][data-name="Delayed"]',
+	);
+
+	await clickSelector(
+		devtools,
+		'[data-testid="tree-item"][data-name="Delayed"]',
+	);
+
+	await clickTestId(devtools, "suspend-action");
+
+	await assertEventually(
+		async () => {
+			const items = await getTreeViewItemNames(devtools);
+			expect(items).to.deep.equal([
+				"Shortly",
+				"Block",
+				"Suspense",
+				"Component", // <10.4.5, newer versions use a Fragment
+				"Block",
+			]);
+			return true;
+		},
+		{ crashOnError: false, timeout: 2000 },
+	);
+
+	const selected = await getAttribute(
+		devtools,
+		'[data-testid="tree-item"][data-selected="true"]',
+		"data-name",
+	);
+
+	expect(selected).to.equal("Suspense");
+
+	await clickSelector(
+		devtools,
+		'[data-testid="tree-item"][data-name="Shortly"]',
+		{ timeout: 2000 },
+	);
+
+	await assertEventually(
+		async () => {
+			const inspected = await getText(
+				devtools,
+				'[data-testid="inspect-component-name"]',
+			);
+			expect(inspected).to.equal("<Shortly>");
+			return true;
+		},
+		{ crashOnError: false, timeout: 2000 },
+	);
+
+	await assertNotTestId(devtools, "suspend-action", { timeout: 2000 });
+}