Skip to content

Commit

Permalink
Add support for suspending components
Browse files Browse the repository at this point in the history
  • Loading branch information
marvinhagemeister committed Sep 22, 2020
1 parent 9ca5496 commit f978a2a
Show file tree
Hide file tree
Showing 12 changed files with 228 additions and 5 deletions.
38 changes: 37 additions & 1 deletion src/adapter/10/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
hasDom,
setNextState,
getHookState,
createSuspenseState,
} from "./vnode";
import { shouldFilter } from "./filter";
import { ID, DevNodeType } from "../../view/store/types";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
},
};
}
33 changes: 31 additions & 2 deletions src/adapter/10/renderer/inspectVNode.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -47,5 +75,6 @@ export function inspectVNode(
state,
// TODO: We're not using this information anywhere yet
type: getDevtoolsType(vnode),
suspended,
};
}
27 changes: 27 additions & 0 deletions src/adapter/10/vnode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
}
3 changes: 3 additions & 0 deletions src/adapter/MultiRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
},
Expand Down
9 changes: 9 additions & 0 deletions src/adapter/adapter/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
});
}
4 changes: 4 additions & 0 deletions src/adapter/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ describe("applyEvent", () => {
props: null,
state: null,
type: "asd",
canSuspend: false,
suspended: false,
};

const data = fromSnapshot([
Expand All @@ -255,6 +257,8 @@ describe("applyEvent", () => {
props: null,
state: null,
type: "asd",
canSuspend: false,
suspended: false,
};

store.sidebar.props.uncollapsed.$ = ["a", "b", "c"];
Expand Down
1 change: 1 addition & 0 deletions src/adapter/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/adapter/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion src/view/components/ComponentName/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
13 changes: 13 additions & 0 deletions src/view/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
);
}
31 changes: 30 additions & 1 deletion src/view/components/sidebar/SidebarActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -31,13 +31,42 @@ 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>

<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}
Expand Down
69 changes: 69 additions & 0 deletions test-e2e/tests/suspense-toggle.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}

0 comments on commit f978a2a

Please sign in to comment.