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 thetabs
property to an empty array - it renders the
<header>
tag - it encounters
{{#each this.tabs as |tab|}}
but becausethis.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 atab
component- it sets
registerTab
andactive
from thetabs
component to the newtab
component. - it executes the
init
hook. - Because of the
this.registerTab(this);
call it will executeregisterTab
on thetabs
component - This will execute
this.get('tabs').pushObject(tab);
. However because thetabs
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.
- it sets
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
- renders everything with an empty
tabs
array, - calls all the
this.get('tabs').pushObject(tab)
for all tabs, effectivly fully populating thetabs
array, and then - rerendering everything with the full
tabs
array.