How to get Xterm.js resize properly?

AWolf picture AWolf · Mar 24, 2019 · Viewed 9k times · Source

tl;dr I've created a React wrapper to render an array of log messages into a terminal but resizing is giving a weird output (see screenshot). (There is a React-Wrapper on NPM but that wasn't working for my use-case - caused screen flickering)

I'm working on a feature for Guppy where I'm adding Xterm.js for the terminal output. The PR can be found here.

I've added xterm because of hyperlink scanning/parsing and that is working.

But I'm stuck with getting resize to work. If I'm starting the devServer in the app and wait for some text it will display with correct letter width. If I reduce the size I'm getting an output with an incorrect letter width. Like in the following screenshot: Messed output

It is always looking correct in the not resized state but after resize it will get the wrong display - so this will happen for enlarging & shrinking the screen width.

The output should look similar to the following screenshot (maybe with some wrapped lines): Correct output

I think this is caused by Fit addon or the way I'm handling resizing with the resize observer but I'm not sure.

The span style of xterm letter are getting a width of NaNpx like in the following screenshot: CSS with width NaNpx Is this caused by a media query I'm using? I haven't seen that yet maybe I have to temporarily disable all media queries to see if that's causing the behaviour.

What I have tried so far:

  • Wrapped this.xterm.fit() into a setTimeout(func, 0) but with-out an effect
  • Modified some of the styles I'm using but I haven't found the cause.

Code

The code I'm using can be found on Github branch feature-terminal-links but here I'd like to extract the parts I've added to get Xterm to work with React:

  1. I created a styled-component XtermContainer as a div so I can add Xterm styles and own styling. The following code is inside render and will be our xterm.js container (innerRef will be used later in ComponentDidMount to intialize Xterm with that container):
<XtermContainer
    width={width}
    height={height}
    innerRef={node => (this.node = node)}
/>
  1. Init xterm in componentDidMount with the container above:
componentDidMount() {
    Terminal.applyAddon(webLinks);
    Terminal.applyAddon(localLinks);
    Terminal.applyAddon(fit);

    this.xterm = new Terminal({
      convertEol: true,
      fontFamily: `'Fira Mono', monospace`,
      fontSize: 15,
      rendererType: 'dom', // default is canvas
    });

    this.xterm.setOption('theme', {
      background: COLORS.blue[900],
      foreground: COLORS.white,
    });

    this.xterm.open(this.node);
    this.xterm.fit();

    /* ... some addon setup code here (not relevant for the problem) ... */
}
  1. Added react-resize-observer inside of the wrapper that is also containing the terminal container so I can trigger this.xterm.fit() if the size changes (in the repo there is a setTimeout wrapper around for testing).
<ResizeObserver onResize={() => this.xterm && this.xterm.fit()} />
  1. Using componentDidUpdate(prevProps, prevState) to update the terminal and scroll the terminal to the bottom if the component is getting new logs:
componentDidUpdate(prevProps, prevState) {
    if (prevProps.task.logs !== this.state.logs) {
      if (this.state.logs.length === 0) {
        this.xterm.clear();
      }
      for (const log of this.state.logs) {
        /*
        We need to track what we have added to xterm - feels hacky but it's working.
        `this.xterm.clear()` and re-render everything caused screen flicker that's why I decided to not use it.
        Todo: Check if there is a react-xterm wrapper that is not using xterm.clear or 
              create a wrapper component that can render the logs array (with-out flicker).
        */
        if (!this.renderedLogs[log.id]) {
          this.writeln(log.text);
          this.xterm.scrollToBottom();
          this.renderedLogs[log.id] = true;
        }
      }
    }
}

Ideas I have to find the cause:

  • Check ResizeObserver code. (see update below)
  • Try to find why xterm css is getting a NaN width. Is Xterm.js using the style width of the container? If yes, maybe that's not correctly set.

Update

OK, the resize obeserver is probably not needed as I'm getting the same behaviour after commenting out the <ResizeObserver/> in render. So I think it's caused by xterm.js or the css in Guppy.

Answer

AWolf picture AWolf · Apr 24, 2019

I have a fix for the issue. It's now working in the above mentioned feature branch. Not sure if there is a better solution but it's working for me.

I like to explain how I have fixed the resizing issue:

The problem was the OnlyOn component that was used in DevelopmentServerPane. It always rendered two TerminalOutput components. One terminal was hidden with display: none and the other was displayed with display: inline - the style change was handled with a media query inside a styled-component.

After replacing OnlyOn with React-responsive and using the render props to check mdMin breakpoint it was working as expected. React-responsive is removing the not displayed mediaquery component from DOM so only one terminal in DOM at the same time.

I still don't know why there was a problem with the letter width but probably the two instances collided somehow. I couldn't create a minimal reproduction. I tried to recreate the issue in this Codesandbox but I have only resized one Terminal at a time and so I haven't got the issue there.

The code that fixed the problem (simplified version from the above mentioned repo):

import MediaQuery from 'react-responsive';

const BREAKPOINT_SIZES = {
  sm: 900,
};

const BREAKPOINTS = {
  mdMin: `(min-width: ${BREAKPOINT_SIZES.sm + 1}px)`,
};

const DevelopmentServerPane = () => (
  <MediaQuery query={BREAKPOINTS['mdMin']}>
    {matches =>
      matches ? (
        <div>{/* ... render Terminal for matching mdMin and above */}</div>
      ) : (
        <div> {/* ... render Terminal for small screens */}</div>
      )
    }
  </MediaQuery>
);