LUX
What ember means by "modified twice in a single render"

Sometimes when working with ember people encounter a message like this one:

Uncaught Error: Assertion Failed: You modified "tabs.length" twice on <tabs@component:tab::ember220> in a single render.
It was rendered in "component:tabs" and modified in "component:tab".
This was unreliable and slow in Ember 1.x and is no longer supported.
See https://github.com/emberjs/ember.js/issues/13948 for more details.

Usually this happens when something that already has been rendered was later modified in the same rendering cycle.

This often happens when a contexual component tries to track its children. Assuming we want a tabbing component that we can use like this:

<Tabs as |t|>
  <t.tab @title="First">
    This is the first
  </t.tab>

  <t.tab @title="Second">
    This is the second
  </t.tab>
</Tabs>

Then we could try to implement this with the following components:

Tabs

<div class="tabbed-context">
  <header>
    {{#each this.tabs as |tab|}}
      <button onclick={{action (mut this.active) tab.title}}>
        {{tab.title}}
      </button>
    {{/each}}
  </header>
  <main>
    {{yield (hash
      tab=(component "tab"
        registerTab=(action 'registerTab')
        active=this.active
      )
    )}}
  </main>
</div>
import Component from '@ember/component';
import { later } from '@ember/runloop';

export default Component.extend({
  tagName: '',
  init() {
    this._super(...arguments);
    this.set('tabs', []);
  },
  actions: {
    registerTab(tab) {
      // this is the problematic line:
      this.get('tabs').pushObject(tab);
    },
  },
});

Tab

{{#if this.isActive}}
  {{yield}}
{{/if}}
import Component from '@ember/component';
import { computed } from '@ember/object';

export default Component.extend({
  tagName: '',
  init() {
    this._super(...arguments);
    this.registerTab(this);
  },
  isActive: computed('active', 'title', {
    get() {
      return this.active === this.title;
    }
  }),
});

Now to understand the problem that happens in the marked line we need to think how we would render this:

  • ember creates the tabs component
  • it executes the init hook and sets the tabs property to an empty array
  • it renders the <header> tag
  • it encounters {{#each this.tabs as |tab|}} but because this.tabs is empty it omits it. This is important, we will come back to this later.
  • it creates the <main> tag
  • it encounters the yield, meaning it goes back to the outer template
  • it encounters <t.tab @title="First"> meaning it has to create a tab component
    • it sets registerTab and active from the tabs component to the new tab component.
    • it executes the init hook.
    • Because of the this.registerTab(this); call it will execute registerTab on the tabs component
    • This will execute this.get('tabs').pushObject(tab);. However because the tabs array was already used to (not) render {{#each this.tabs as |tab|}} we've modified something that was already rendered which we should not. Resulting in the error.

The reason why we should not do this is because at that point ember basically has to abort the rendering and jump back to that point. If this happens multiple times (here for every tab) this will result in poor performance.

Now the fix is to delay that modification. For this we can wrap this.get('tabs').pushObject(tab); inside later:

import { later } from '@ember/runloop';
...

later(() => this.get('tabs').pushObject(tab));

this will modify the tabs array after the initial rendering. This means that ember

  1. renders everything with an empty tabs array,
  2. calls all the this.get('tabs').pushObject(tab) for all tabs, effectivly fully populating the tabs array, and then
  3. rerendering everything with the full tabs array.