0

I'm trying to display large text documents with complex markups using the react-virtualized List component. The document is broken up into chunks of text of varying lengths shorter than some maximum. The List component renders each of these chunks as a row.

Since I can't use fixed row heights because chunk lengths vary, I'd like to use CellMeasurer. The problem is that the parsing needed to generate the markups on each chunk is expensive -- this is part of the reason I want to use react-virtualized. Even if all the chunks are rendered in the background, it will still be too slow.

Since the markup does not affect height, I'd like to use a simpler rowRenderer function that renders text without markup only for measuring rows, and then provide a separate and more complete rowRenderer for the actual render of each chunk with markup. Is there a way to do this?

jives
  • 5
  • 1
  • 3

2 Answers2

0

This is admittedly really hacky, and not best practices, but could you do something like:

<List
    rowHeight={(index) => {
        const rowData = data[index];
        let div = document.getElementById('renderer');
        if (!div) {
            div = document.createElement('div');
            div.id = 'renderer';
        }
        ReactDOM.render(div, <Row>{rowData}</Row>);
        const height = div.offsetHeight;
        ReactDOM.unmountComponentAtNode(div);
        if (index === data.length - 1) {
            div.parentNode.removeChild(div);
        }
        return height;
    }}
/>

Or, actually, you could have two lists, one with visibility: hidden, where you just render each row without markup, get the height, and add it to an array. Once the length of the array is equal to your data length, you no longer show it, and then render the other one, with rowHeight={index => heights[index]}

dave
  • 62,300
  • 5
  • 72
  • 93
  • Thanks dave, but I'd much rather not construct my own background render or do any DOM gymnastics if I can avoid it. I've found another simpler way to do it using more ordinary react practices. (Will post shortly.) – jives Aug 06 '20 at 13:13
0

After some trial and error I found a good way to do this is to make the rowRenderer function decide which way to render the row. You can do this by checking the _rowHeightCache property in the CellMeasurerCache instance use use in your list. Note that the keys of _rowHeightCache take the form: "index-0" where index is the row's index.

Here is how you can set up the row renderer:

  TextRowRenderer(
    {
      key, // Unique key within array of rows
      index, // Index of row within collection
      // isScrolling, // The List is currently being scrolled
      style, // Style object to be applied to row (to position it)
      parent, // reference to List
    },
  ) {
    const { textArray } = this.props;

    // get the cached row height for this row
    const rowCache = this.listCache._rowHeightCache[`${index}-0`];

    // if it has been cached, render it using the normal, more expensive
    // version of the component, without bothering to wrap it in a
    // CellMeasurer (it's already been measured!)

    // Note that listCache.defaultHeight has been set to 0, to make the
    // the comparison easy
    if (rowCache !== null && rowCache !== undefined && rowCache !== 0) {
      return (
        <TextChunk
          key={key}
          chunk={textArray[index]}
          index={index}
          style={style}
        />
      );
    }

    // If the row height has not been cached (meaning it has not been
    // measured, return the text chunk component, but this time:
    // a) it's wrapped in CellMeasurer, which is configured with the
    //    the cache, and
    // b) it receives the prop textOnly={true}, which it tells it to
    //    to skip rendering the markup
    return (
      <CellMeasurer
        cache={this.listCache}
        columnIndex={0}
        key={key}
        parent={parent}
        rowIndex={index}
      >
        {() => {
          return (
            <TextChunk
              key={key}
              chunk={textArray[index]}
              index={index}
              style={style}
              textOnly
            />
          );
        }}
      </CellMeasurer>
    );
  }

This is then passed to the List component in the ordinary way:

          <List
            height={pageHeight}
            width={pageWidth}
            rowCount={textArray.length}
            rowHeight={this.listCache.rowHeight}
            rowRenderer={this.TextRowRenderer}
            deferredMeasurementCache={this.listCache}
            style={{ outline: 'none' }}
            ref={(r) => { this.listRef = r; }}
          />

By including a reference callback, we can now access the measureAllRows method, which we can use to force the List to render all rows in advance to get the heights. This will ensure that the scroll bar functions properly, but even with the textOnly flag can take a while longer. In my case, I believe it is worth the wait.

Here is how you can call measureAllRows:

  componentDidUpdate() {
    const {
      textArray,
    } = this.props;

    // We will only run measureAllRows if they have not been
    // measured yet. An easy way to check is to see if the
    // last row is measured
    const lastRowCache = this
       .listCache
       ._rowHeightCache[`${textArray.length - 1}-0`];

    if (this.listRef
      || lastRowCache === null
      || lastRowCache === undefined
      || lastRowCache === 0) {
      try {
        this.listRef.measureAllRows();
      } catch {
        console.log('failed to measure all rows');
      }
    }
  }

The try-catch block is needed because if it tries to measure before the List is constructed it will throw an index-out-of -bounds error.

FINAL THOUGHTS

Another possible way to do this would be to have the actual List component be conditional on cached measurements rather than the rowRenderer function. You could render an array of text as a a regular list, wrapping each row in a <CellMeasurer>. When the cache is filled, you would then render a virtual List configured using the prefilled cache. This would obviate the need to call measureAllRows in componentDidUpdate or a useEffect callback.

jives
  • 5
  • 1
  • 3