Significant performance degradation when loading large number of components

Hey all!

We are looking into building out a component similar to Ember-Light-Table and hit a bit of a snag. This component utilizes component contextualization to improve on flexibility and implementation.

An example setup:

{{#my-table model=dataModel as |table|}}
    {{table.column name="Column 1" property="dataAttribute"}}
    {{table.column name="Column 2" property="dataAttribute"}} 
    {{#table.column name="Column 3" property="dataAttribute" as |column|}} 
        {{some-helper column.data}}
    {{/table.column}}
{{/my-table}}

To put in place a cleaner, more ā€œmodern webā€ UI, we are looking to set up a table without pagination. Or, if need be, setting the threshold high enough that most users never see it.

The catch: this needs to be ADA compliant, removing solutions like Vertical Collections - unfortunately, Infinite scrolling just does not work very well with screen readers.

With a reasonable number of records, there is no issueā€¦ but you start encountering long running script errors in IE at around 250 records.

Using Ember Concurrency, weā€™ve managed to suppress these errorsā€¦ though the records still take a significant amount of time to load, lagging the UI a bit in the process.

Gathering some metrics, Iā€™ve found that Ember components take around 1ms to render. This means that a table of six columns and 400 rows takes - as a baseline - 2.4 seconds without any overhead. With computed properties and such, Iā€™m seeing about double that number.

Has anyone run into an issue like this one? (rendering a large number of elements takes a massive amount of time)

If so, how did you solve for it? Do you know of a way to streamline the render? From what I am seeing, it looks as if a significant amount of time is spent drawing to the screen. This being the case, a potential fix would entail some kind of ā€œbufferā€. That is, instead of many small changes to the DOM, buffer the changes and write them all at once. (or in chunks)

Let me know what you think.

Thanks for the help!

2 Likes

What Ember version are you on?

We are on 2.14.1, though Iā€™ve tested with 3.1.1 and actually noticed even worse metrics.

Are you testing a production build (--environment=production)? Just checking because the performance characteristics are very different if you just run ember s.

There is definitely a difference, individual component rendering goes from 1.3ms/component in a Prod build to 1.7ms/component in a Dev build.

Fairly substantial difference (almost 30% improvement), but this still means that a 400 record table with 6 columns takes over 3 seconds to render in IE (at best).

1 Like

If your use case is really slower in a 3.1.1 production build than a 2.14.1 production build, that is interesting and if you can share a working reproduction it would be worth filing an issue about it.

But in terms of solving your immediate problem, there are several possible paths.

Best option: make vertical-collection accessible

I think the very best option would be to come up with a strategy for making vertical-collection itself accessible. This would benefit a huge number of users while also solving your problem.

My hunch is that we could provide more traditional pagination controls that only screenreaders see. Sighted users would get the current behavior of vertical-collection. Screen-reader users would hear the first page of results, and also hear links for next page, jump to page N/M, etc. Those links could programmatically jump the vertical-collection to the right places. After it rerenders with new rows in DOM, you might need to give a hint to screenreaders to re-read the changed content (this is already an issue for plain old {{outlet}} too, so you can reuse whatever strategy youā€™re using there. ember-a11y uses focus manipulation).

I would suspect that these added pagination links would have accessibility benefits for keyboard users too, not just screenreader users, since you could tab over to the links and activate them all by keyboard. That may be an argument for showing them to everyone, at least as an option for people installing vertical-collection. But a more elegant solution to that part of the problem is probably to give the vertical-collection its own native keyboard controls so that the table can be focused and scrolled with up and down keys.

It would not surprise me if this strategy is actually better than dumping all the rows into the DOM, because that strategy doesnā€™t give screen reader or keyboard users an especially good way to navigate thousands of rows. I am not an accessibility expert, all of this deserves real user testing.

Second best: optimize your giant table

But assuming you really need to keep all the rows in DOM, that does cut out the most accessible prepackaged optimization strategies, which are all about limiting the work to things that are on screen. Youā€™re setting yourself a hard task, which is rendering vastly more stuff than could even all appear on screen at once (or even be read productively by a screen reader ā€“ because really, whoā€™s going to listen patiently through thousands of cells?).

The most impactful thing is probably to have less components.

Your code snippet doesnā€™t make it clear how many you already have, because we canā€™t see how table.column is implemented. If it has a component per cell, yeah, that is probably pretty expensive. You will get a boost by not doing that.

Often you can do this kind of refactor without losing any features. If the cell component has computed properties, move that work into helpers. If it has actions, move the actions up to a parent component, pass the actions down, and parameterize the arguments to the action so that you can still operate on the correct cell data.

You can still give each cell customized content by yielding, just as you do in your example. Though keep in mind that youā€™ll keep your component count lower if you inline some DOM like

{{#table.column name="Column 3" property="dataAttribute" as |column|}} 
    <div class="some-special" {{action "mogrifyTheData" column}}>column.data</div>
{{/table.column}}

rather than invoke a component per cell:

{{#table.column name="Column 3" property="dataAttribute" as |column|}} 
    {{some-component column.data}}
{{/table.column}}

There is a tradeoff here between developer convenience and runtime speed. Itā€™s a tradefoff we are constantly working to bend, and is has gotten steadily better. But if you want to cheat the tradeoff, Miguel Camba has a library that helps you do compile-time components so that you can author with components but not pay their cost at runtime. And hereā€™s a video about how it works and how to use it. This is using nonstable APIs, so if it breaks you get to keep both pieces. How practical it is for you depends on your willingness to step in and learn how it works and fix it if it needs updating.

Sticking to safer public APIs, another option is to write a helper that builds up some DOM Elements and returns them. You can do whatever you want to construct optimized DOM in the helper. This can be cheaper alternative to a component for reusable bits that donā€™t need a lot of fancy behavior. Just be aware that if any of the inputs to the helper change, itā€™s going to rerun completely, and rebuild all of the DOM that it built, so anything stateful in there like <input> could get blown away.

Another option that is all public API, though new, is to enable the template-only-glimmer-components feature, and refactor your most common components to be template-only glimmer components. Youā€™d do that by:

  1. Use Ember 3.1
  2. Add the @ember/optional-features package to your project.
  3. Run ember feature:enable template-only-glimmer-components.
  4. Refactor your most used components (like per-cell ones) to have no Javascript files, only templates files.
  5. Adjust their templates to the new glimmer component semantics, which means:
    • they will no longer get an automatic wrapping div ā€“ you get to put the outermost div directly in the template.
    • they should refer to their arguments like {{@whatever}} instead of {{whatever}}.

I havenā€™t measured the impact of this, but it allows Glimmer to skip a bunch of work, so it seems like it could measurably help your situation. To avoid needing a javascript file, you would do many of the same things I suggested earlier, like refactoring actions into parent components and refactoring computed properties into helpers.

6 Likes

Unfortunately, I am unable to share my codeā€¦ however, you know one of my colleagues, theyā€™ll ping you shortly with some ideas.

Make vertical-collection accessible

This would definitely help, but there is an inherent issue with infinite scrolling solutions not playing well with screen readers. We have been talking a little bit with a couple of the Smoke and Mirrors devs, and will likely be jumping on a call to discuss issues (including this one), as well as planning on solutions moving forward once runspired is back.

Pagination would be a great idea, as that is the normal solution in making large data sets accessible, we will just need to make sure there is an easy way for screen reader users to filter through a large data set - I will play around with this idea to see how well it worksā€¦ as it very well might solve many of my issues.

Generally, dumping a ton of data into a page breaks accessibility convention beyond just the simple ā€œscreen readers have issues with the dataā€. While doing so does not technically break any rules within WCAGā€¦ in my opinion, it definitely breaks the spirit of the third principle (content being understandable). So introducing some controls to allow a user the ability to not infinitely scroll through some content would definitely be a boon for users that are not only visually impaired, but users with some form of cognitive impairment.

Optimize my giant table

This is likely something that will need to happen even with the introduction of pagination controls within Vertical Collections, as Iā€™ve noticed a small bit of lag within the VC addon in IE on scroll. Stripping out pretty much everything, defining tagName as blank and defining it within the template, and handling events higher up in the chain, Iā€™ve reduced the per-component render time down to around 0.76ms. Still pretty high, as 250 row/6 column table takes a couple seconds to renderā€¦ but definitely getting closer to a reasonable render time.

Glimmer Components

Definitely something Iā€™ve also been looking into. I was having issues getting them into the Ember App, however, so I will most certainly give this a try.

Thanks for the advice! It is most certainly appreciated!

Best,

Jason

Regarding @ef4ā€™s ā€œSecond best: optimize your giant tableā€ helper suggestion: One thing thatā€™s worked well for us is instead of generating markup with the helper, just prep all the data in the helper and pass it to a static TD.

Table template:

<table>
  <tbody>
    {{#each data as |datum|}}
      <tr>
        {{#each columns as |column|}}
          {{#with (x-table/decorate-cell
              (get datum column.valuePath)
              column=column
              datum=datum
            )
            as |cell|
          }}
            <td class={{cell.classNames}} style={{cell.style}}>
              <div title={{cell.defaultTitle}}>
                {{cell.value}}
              </div>
            </td>
          {{/with}}
        {{/each}}
      </tr>
  </tbody>
</table>

Here we:

  1. Prep data and columns in the table component or outer context.
  2. Loop through each column and pass it to the x-table/decorate-cell helper.
  3. Using the with helper, expose the decorated cell to the TD.

This cut our render time significantly.

Template-only components also cut the render time significantly.

Like @jcorradino, vertical-collection was not an option for us due to perceived ā€˜jankā€™ when scrolling up and down quickly and seeing the empty/not-yet-rendered table cells. In its stead, what worked well was a recursive method thatā€™s called in didReceiveAttrs. This method splits the rows into chunks and renders one chunk per runloop. Set the chunk size to the number of rows that are initially visible on the first page render. This approach was fast and doesnā€™t make the user pay the price of waiting for render more than once i.e., when they scroll back up, the rows that were rendered already are still in the DOM.

1 Like

Just wanted to pipe in with my $.02 on this oneā€¦

I have a page that renders THOUSANDS of small divs. There is no way to do any sort of lazy loading, and unfortunately many of them need listeners on the same computed properties, etc. Needless to say, I had some performance issues, and the app operated very poorly and sluggishly.

To correct this, (using the advice here) I used template only glitter components, the {{let}} block, and helpers to solve the problem.

ember install @ember/optional-features

Then in config/option-features.json

{
  "template-only-glimmer-components": true
}

HUGE difference in performance.