Skip to content

Commit

Permalink
Add support for React Devtools (#287)
Browse files Browse the repository at this point in the history
This change adds support for debugging Ink apps using React Devtools.
All you need to do is run your CLI with `DEV=true` environment variable set.

```
$ DEV=true my-cli
```

Then, start up React Devtools itself to debug:

```
$ npx react-devtools
```

After it starts up, you should see the component tree of your CLI.
You can even inspect and change the props of components,
and see the results immediatelly in the CLI, without restarting it.
  • Loading branch information
Vadim Demedes committed May 7, 2020
1 parent 2913ecf commit 1a44aac
Show file tree
Hide file tree
Showing 17 changed files with 165 additions and 39 deletions.
Binary file added media/devtools.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -58,6 +58,7 @@
"is-ci": "^2.0.0",
"lodash.throttle": "^4.1.1",
"prop-types": "^15.6.2",
"react-devtools-core": "^4.6.0",
"react-reconciler": "^0.24.0",
"scheduler": "^0.18.0",
"signal-exit": "^3.0.2",
Expand All @@ -66,6 +67,7 @@
"type-fest": "^0.12.0",
"widest-line": "^3.1.0",
"wrap-ansi": "^6.2.0",
"ws": "^7.2.5",
"yoga-layout-prebuilt": "^1.9.5"
},
"devDependencies": {
Expand Down
23 changes: 23 additions & 0 deletions readme.md
Expand Up @@ -80,6 +80,7 @@ Feel free to play around with the code and fork this repl at [https://repl.it/@v
- [Hooks](#hooks)
- [Useful Components](#useful-components)
- [Testing](#testing)
- [Using React Devtools](#using-react-devtools)

## Getting Started

Expand Down Expand Up @@ -1114,6 +1115,28 @@ lastFrame() === 'Hello World'; //=> true

Visit [ink-testing-library](https://github.com/vadimdemedes/ink-testing-library) for more examples and full documentation.

## Using React Devtools

![](media/devtools.jpg)

Ink supports [React Devtools](https://github.com/facebook/react/tree/master/packages/react-devtools) out-of-the-box.
To enable integration with React Devtools in your Ink-based CLI, run it with `DEV=true` environment variable:

```
$ DEV=true my-cli
```

Then, start React Devtools itself:

```
$ npx react-devtools
```

After it starts up, you should see the component tree of your CLI.
You can even inspect and change the props of components, and see the results immediatelly in the CLI, without restarting it.

**Note**: You must manually quit your CLI via <kbd>Ctrl</kbd>+<kbd>C</kbd> after you're done testing.

## Maintainers

- [Vadim Demedes](https://github.com/vadimdemedes)
Expand Down
1 change: 1 addition & 0 deletions src/components/App.tsx
Expand Up @@ -21,6 +21,7 @@ interface Props {
// It renders stdin and stdout contexts, so that children can access them if needed
// It also handles Ctrl+C exiting and cursor visibility
export class App extends PureComponent<Props> {
static displayName = 'InternalApp';
static propTypes = {
children: PropTypes.node.isRequired,
stdin: PropTypes.object.isRequired,
Expand Down
2 changes: 2 additions & 0 deletions src/components/AppContext.ts
Expand Up @@ -10,3 +10,5 @@ export interface AppContextProps {
export const AppContext = createContext<AppContextProps>({
exit: () => {}
});

AppContext.displayName = 'InternalAppContext';
1 change: 1 addition & 0 deletions src/components/Box.tsx
Expand Up @@ -11,6 +11,7 @@ export type BoxProps = Styles & {
* `<Box>` it's an essential Ink component to build your layout. It's like a `<div style="display: flex">` in a browser.
*/
export class Box extends PureComponent<BoxProps> {
static displayName = 'Box';
static propTypes = {
display: PropTypes.oneOf(['flex', 'none']),
margin: PropTypes.number,
Expand Down
10 changes: 7 additions & 3 deletions src/components/Color.tsx
Expand Up @@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import arrify from 'arrify';
import chalk, {Chalk} from 'chalk';
import {Except} from 'type-fest';
import {Transform} from './Transform';
import Transform from './Transform';

type Colors =
| typeof chalk.ForegroundColor
Expand Down Expand Up @@ -46,7 +46,7 @@ const methods = [
/**
* The `<Color>` compoment is a simple wrapper around the `chalk` API. It supports all of the `chalk`'s methods as `props`.
*/
export const Color: FC<ColorProps> = memo(({children, ...colorProps}) => {
const Color: FC<ColorProps> = ({children, ...colorProps}) => {
if (children === '') {
return null;
}
Expand All @@ -69,7 +69,9 @@ export const Color: FC<ColorProps> = memo(({children, ...colorProps}) => {
};

return <Transform transform={transform}>{children}</Transform>;
});
};

Color.displayName = 'Color';

Color.propTypes = {
children: PropTypes.node
Expand All @@ -78,3 +80,5 @@ Color.propTypes = {
Color.defaultProps = {
children: ''
};

export default memo(Color);
2 changes: 2 additions & 0 deletions src/components/Static.tsx
Expand Up @@ -56,6 +56,8 @@ export const Static = <T,>(props: StaticProps<T>) => {
);
};

Static.displayName = 'Static';

Static.propTypes = {
items: PropTypes.array.isRequired,
style: PropTypes.object,
Expand Down
2 changes: 2 additions & 0 deletions src/components/StderrContext.ts
Expand Up @@ -21,3 +21,5 @@ export const StderrContext = createContext<StderrProps>({
stderr: undefined,
write: () => {}
});

StderrContext.displayName = 'InternalStderrContext';
2 changes: 2 additions & 0 deletions src/components/StdinContext.ts
Expand Up @@ -24,3 +24,5 @@ export const StdinContext = createContext<StdinContextProps>({
setRawMode: () => {},
isRawModeSupported: false
});

StdinContext.displayName = 'InternalStdinContext';
2 changes: 2 additions & 0 deletions src/components/StdoutContext.ts
Expand Up @@ -21,3 +21,5 @@ export const StdoutContext = createContext<StdoutContextProps>({
stdout: undefined,
write: () => {}
});

StdoutContext.displayName = 'InternalStdoutContext';
64 changes: 33 additions & 31 deletions src/components/Text.tsx
@@ -1,7 +1,7 @@
import React, {FC, ReactNode, memo} from 'react';
import PropTypes from 'prop-types';
import chalk from 'chalk';
import {Transform} from './Transform';
import Transform from './Transform';

export interface TextProps {
readonly bold?: boolean;
Expand All @@ -15,42 +15,42 @@ export interface TextProps {
/**
* This component can change the style of the text, make it bold, underline, italic or strikethrough.
*/
export const Text: FC<TextProps> = memo(
({
bold,
italic,
underline,
strikethrough,
children,
unstable__transformChildren
}) => {
const transform = (children: ReactNode) => {
if (bold) {
children = chalk.bold(children);
}
const Text: FC<TextProps> = ({
bold,
italic,
underline,
strikethrough,
children,
unstable__transformChildren
}) => {
const transform = (children: ReactNode) => {
if (bold) {
children = chalk.bold(children);
}

if (italic) {
children = chalk.italic(children);
}
if (italic) {
children = chalk.italic(children);
}

if (underline) {
children = chalk.underline(children);
}
if (underline) {
children = chalk.underline(children);
}

if (strikethrough) {
children = chalk.strikethrough(children);
}
if (strikethrough) {
children = chalk.strikethrough(children);
}

if (typeof unstable__transformChildren === 'function') {
children = unstable__transformChildren(children);
}
if (typeof unstable__transformChildren === 'function') {
children = unstable__transformChildren(children);
}

return children;
};
return children;
};

return <Transform transform={transform}>{children}</Transform>;
}
);
return <Transform transform={transform}>{children}</Transform>;
};

Text.displayName = 'Text';

/* eslint-disable react/boolean-prop-naming */
Text.propTypes = {
Expand All @@ -70,3 +70,5 @@ Text.defaultProps = {
strikethrough: false,
unstable__transformChildren: undefined
};

export default memo(Text);
8 changes: 6 additions & 2 deletions src/components/Transform.tsx
Expand Up @@ -9,17 +9,21 @@ export interface TransformProps {
/*
* Transform a string representation of React components before they are written to output.
*/
export const Transform: FC<TransformProps> = memo(({children, transform}) => (
const Transform: FC<TransformProps> = ({children, transform}) => (
<span
style={{flexDirection: 'row'}}
// @ts-ignore
internal_transform={transform}
>
{children}
</span>
));
);

Transform.displayName = 'Transform';

Transform.propTypes = {
transform: PropTypes.func.isRequired,
children: PropTypes.node.isRequired
};

export default memo(Transform);
66 changes: 66 additions & 0 deletions src/devtools.ts
@@ -0,0 +1,66 @@
// Ignoring missing types error to avoid adding another dependency for this hack to work
// @ts-ignore
import ws from 'ws';

const customGlobal = global as any;

// These things must exist before importing `react-devtools-core`
customGlobal.WebSocket = ws;
customGlobal.window = global;

// Filter out Ink's internal components from devtools for a cleaner view.
// Also, ince `react-devtools-shared` package isn't published on npm, we can't
// use its types, that's why there are hard-coded values in `type` fields below.
// See https://github.com/facebook/react/blob/edf6eac8a181860fd8a2d076a43806f1237495a1/packages/react-devtools-shared/src/types.js#L24
customGlobal.window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = [
{
// ComponentFilterElementType
type: 1,
// ElementTypeHostComponent
value: 7,
isEnabled: true
},
{
// ComponentFilterDisplayName
type: 2,
value: 'InternalApp',
isEnabled: true,
isValid: true
},
{
// ComponentFilterDisplayName
type: 2,
value: 'InternalAppContext',
isEnabled: true,
isValid: true
},
{
// ComponentFilterDisplayName
type: 2,
value: 'InternalStdoutContext',
isEnabled: true,
isValid: true
},
{
// ComponentFilterDisplayName
type: 2,
value: 'InternalStderrContext',
isEnabled: true,
isValid: true
},
{
// ComponentFilterDisplayName
type: 2,
value: 'InternalStdinContext',
isEnabled: true,
isValid: true
}
];

// Ignoring missing types error to avoid adding another dependency for this hack to work
// @ts-ignore
import {connectToDevTools} from 'react-devtools-core';

if (process.env.DEV === 'true') {
connectToDevTools();
}
6 changes: 3 additions & 3 deletions src/index.ts
@@ -1,13 +1,13 @@
export {render, RenderOptions, Instance} from './render';
export {Box, BoxProps} from './components/Box';
export {Text, TextProps} from './components/Text';
export {Color, ColorProps} from './components/Color';
export {default as Text, TextProps} from './components/Text';
export {default as Color, ColorProps} from './components/Color';
export {AppContext, AppContextProps} from './components/AppContext';
export {StdinContext, StdinContextProps} from './components/StdinContext';
export {StdoutContext, StdoutContextProps} from './components/StdoutContext';
export {StderrProps} from './components/StderrContext';
export {Static, StaticProps} from './components/Static';
export {Transform, TransformProps} from './components/Transform';
export {default as Transform, TransformProps} from './components/Transform';
export {useInput, Key} from './hooks/use-input';
export {useApp} from './hooks/use-app';
export {useStdin} from './hooks/use-stdin';
Expand Down
11 changes: 11 additions & 0 deletions src/ink.tsx
Expand Up @@ -84,6 +84,17 @@ export class Ink {

// Unmount when process exits
this.unsubscribeExit = signalExit(this.unmount, {alwaysLast: false});

if (process.env.DEV === 'true') {
reconciler.injectIntoDevTools({
bundleType: 0,
// Reporting React DOM's version, not Ink's
// See https://github.com/facebook/react/issues/16666#issuecomment-532639905
version: '16.13.1',
rendererPackageName: 'ink',
findHostInstanceByFiber: reconciler.findHostInstance
});
}
}

resolveExitPromise: () => void = () => {};
Expand Down
2 changes: 2 additions & 0 deletions src/reconciler.ts
Expand Up @@ -19,6 +19,8 @@ import {
ElementNames,
DOMElement
} from './dom';
// eslint-disable-next-line import/no-unassigned-import
import './devtools';

const cleanupYogaNode = (node: Yoga.YogaNode): void => {
// @ts-ignore
Expand Down

0 comments on commit 1a44aac

Please sign in to comment.