Two tracked tales – why does one of them work?

Howdy,

I’m striving to understand the intricacies of tracked properties in Ember and came through the following interesting case (while rebuilding the Rock & Roll book for Octane). I’m going to show you two scenarios: both of them work but I have no idea why the second one does.

What I wanted to achieve is to have a new Band object create and have the list of all bands instantly update with the new band. Ember Data is not used at this point.

Common parts

We have a bands route and a bands.new route nested inside it.

In the bands.new controller, we create the band and add it to the catalog – and then transition back to the bands route:

export default class BandsNewController extends Controller {
  @service catalog;

  @action
  saveBand() {
    let band = new Band({ name: this.name, slug: dasherize(this.name) })
    this.catalog.add('band', band);
    this.transitionToRoute('bands');
  }
}

Now, how we render the list of bands and what we track in the catalog slightly differs, let’s see how.

Scenario 1

The catalog service is very similar to the Ember Data store. It looks like this:

import Service from '@ember/service';
import { tracked } from 'tracked-built-ins';

export default class CatalogService extends Service {
  storage = {};

  constructor() {
    super(...arguments);
    this.storage.bands = tracked([]);
    this.storage.songs = tracked([]);
  }

  add(type, record) {
    if (type === 'band') {
      this.storage.bands.push(record);
    }
    if (type === 'song') {
      this.storage.songs.push(record);
    }
  }
}

As you see, I use the excellent tracked-built-ins add-on to mark the collections (the arrays) as tracked so any operation that mutates the arrays (like the push in the add method) will cause a re-computation (re-render).

We can then iterate through the collection of bands in the catalog to display the bands in bands.hbs:

{{#each this.catalog.storage.bands as |band|}}
  <li class="mb-2">
    <LinkTo @route="bands.band.songs" @model={{band.slug}}>
      {{band.name}}
    </LinkTo>
  </li>
{{/each}}

In this case, the model hook doesn’t even need to return anything as we don’t use @model in the template to render the bands.

When a new band is created, it gets added to the bands array in the catalog via this.storage.bands.push(record). Since the array is tracked, this updates this.catalog.storage.bands in the template, and the new band appears.

Scenario 2

We can follow a more “classic” approach and render @model in the template:

{{#each @model as |band|}}
  <li class="mb-2">
    <LinkTo @route="bands.band.songs" @model={{band.slug}}>
      {{band.name}}
    </LinkTo>
  </li>
{{/each}}

We need to then return the list of bands from the corresponding route, bands:

export default class BandsRoute extends Route {
  @service catalog;

  model() {
    return this.catalog.storage.bands;
  }
}

We also slightly change the catalog service. We no longer track the type-specific arrays, only the storage object itself:

import { tracked } from '@glimmer/tracking';

export default class CatalogService extends Service {
  @tracked storage = {};

  constructor() {
    super(...arguments);
    this.storage.bands = [];
    this.storage.songs = [];
  }

  add(type, record) {
    if (type === 'band') {
      this.storage.bands.push(record);
    }
    if (type === 'song') {
      this.storage.songs.push(record);
    }
  }
}

As you see, I also changed back to using the built-in @glimmer/tracking. The add method didn’t change one bit.

What’s surprising to me is that this also works. I’ve verified that the model hook in the bands route doesn’t get re-run. However, the @each loop in bands.hbs does get re-run: that’s why we see the new band appear in the list.

I don’t understand, why, though. It’s only storage itself that’s tracked, not the bands array inside it. storage is not mutated, only the bands array (when pushing a new object to it). I’m not reassigning storage or even its bands property anywhere. And yet it works.

Update: if I remove the this.transitionToRoute('bands') from the saveSong action, however, then this scenario stops working – which makes me think it’s necessary to force a re-render of bands.hbs. However, I still don’t understand why @model is recomputed – as it’s bound to this.catalog.storage.bands, which we do not track.

I’m very keen to learn why it does. Thank you.

2 Likes

Is {{#each @model as |band|}} inside the bands template or the bands.index template?

Assuming bands.index, that is the explanation. It gets rendered from scratch when you transition.

The bands model hook doesn’t rerun because both bands.new and bands.index are under bands, so bands is stable. But bands.index model hook would rerun, if it had one. Assuming it doesn’t have one, it inherits the model from bands, which is why it can still say @model and get the list of bands.

So the tracking in scenario two really doesn’t work, you’re just getting a render for unrelated reasons. You can confirm this by adding an “add random band” button directly on the page that renders the list of bands, that calls an action that pushes a new band onto the list. I don’t think it will render, but then if you transition away and back it will render.

1 Like

Hey Ed,

Thanks for the answer.

What you write makes sense but the {{#each @model as |band|}} is in app/bands.hbs, not app/bands/index.hbs. If I put a {{log band.slug}} inside the each loop, I see the slug of the new band printed out after the creation hence my saying that the each does get re-run (the model hook doesn’t).

I think this might be related to this:

If you change the model bit to this, I think it mighit work.

import {get} from "@ember/object"
export default class BandsRoute extends Route {
  @service catalog;

  model() {
    return get(this.catalog.storage, "bands");
  }
}

Hey Scott,

Thank you. I think I have the opposite problem: I’d expect a piece of UI not to re-render (see Scenario 2 above) and yet it does.

1 Like

Hello. What happens when you move the form and @action saveBand from BandsNewRoute to BandsRoute template & controller? Do you still see the new band appear?

I was wondering if, by not calling transitionToRoute, we could narrow down the problem to just route hooks or just tracked.

(And just to confirm, are you using <form {{on "submit" this.saveBand}}> to add the band? I didn’t see a event.preventDefault() in the saveBand, so I wasn’t sure what the template looked like.)

Hey Isaac,

Indeed, if I remove the transitionToRoute line than Scenario 2 no longer works (no longer updates the list of bands). I added this now to the original post. So it really seems like you need to force a re-render of the template but I still wonder why @model recomputes? It’s bound to this.catalog.storage.bands which is not tracked.

I use a {{on "click" this.saveBand}} on the button. Do you think it matters?

1 Like

Great, thanks for checking!

I wanted to get clarification on the template so that we can all start from the same assumptions. For example, just by looking at BandsNewController, it’s hard to tell whether you have a child route bands.new or a sibling route bands-new. (I have your book to check but others may not. :smile:) The <form {{on "submit" this.saveBand}}> question was to double-check that you didn’t accidentally refresh the page and the catalog service persisted your bands data.

Based on the router structure that you showed in your book (latest Octane edition), I made a demo app that people can use to check what’s going on: https://github.com/ijlee2/two-tracked-tales . I hope the code matches yours well.

I checked that moving the form from bands.new to bands didn’t trigger the re-render. I think the problem might lie with push. Somehow, it seemed to trigger re-render when the route transitioned from bands.new to bands. More surprisingly, even if I didn’t mark storage as tracked, the re-render happened. :face_with_raised_eyebrow:

When I used an immutable approach (create a new bands array), I didn’t see the re-render after route transition, until I visited another route (e.g. index) and came back to bands.

If push indeed is what caused the problem, I wonder if, in your Octane edition, you might want to follow the immutable approach (just to be safe with arrays and objects) until you can introduce tracked-built-ins.

Thank you very much, Isaac, that’s a very precise minimal repro of my issue. It does indeed seem like it’s the push that causes the “dirtying” and thus the re-render but I have no idea why that’d be the case.

I’ll consider going with the immutable approach but if I don’t make the bands array tracked with tracked-built-ins, I’ll have to reassign the whole of this.storage every time which seems a bit too much.

I’ll have to think this through.

Dumb question (and I haven’t looked at the repo!): do you have array prototype extensions enabled?

I have the following in config/environment.js, so I don’t think so?

  EXTEND_PROTOTYPES: {
    // Prevent Ember Data from overriding Date.parse.
    Date: false
  }

I haven’t tweaked anything on the default config either.

Is there another place where they can be enabled?

@chriskrycho You’re referring to https://guides.emberjs.com/release/configuring-ember/disabling-prototype-extensions/, right? No, I didn’t change any configuration, I think. My app was a fresh install of Ember 3.16.1 + ember-cli-sass to help with styling.

I believe the default has array prototype extensions enabled, though? You have to manually disable them:

ENV = {
  EmberENV: {
    EXTEND_PROTOTYPES: {
      Array: false
    },
  },
};

I would test whether making that change gives you the behavior you originally expected—because the Array prototype extension specifically enables observation of changes to values in the array.

(That said, given it’s push and not pushObject, I wouldn’t expect it to make a difference here.)

Thanks, Chris, for taking the time to reply.

Indeed, telling Ember to not extend the Array prototype didn’t change the behavior. It still updates the list if we transition back to the bands route and doesn’t update if the transition is missing.

Somebody – I think maybe @pzuraq ? – was talking about debugging infrastructure for the tracked system that can tell you why things invalidated. It’s a thing we want to build into the ember inspector eventually, but I think perhaps some of the raw infrastructure already exists and could help here.

1 Like

Unfortunately it doesn’t really exist quite yet. It wouldn’t be terribly hard to build, with context (happy to help work with someone on that if anyone is interested), but it would take a bit of work to get things to be useful.

I also don’t believe it would help us here, because it appears that this is not due to autotracking at all :upside_down_face:

Prior to autotracking, tags existed in Glimmer already, and they worked a bit differently. They were managed manually. They would be assigned, read, and combined manually to create something similar to the autotracking stack that exists today. There are still a few parts of the VM that do this, though I’m working on getting rid of them.

It appears that the tag for {{@model}} is the tag of the outlet: /packages/@ember/-internals/glimmer/lib/syntax/outlet.ts#L95

The tag of an outlet is that tag of its parent: /packages/@ember/-internals/glimmer/lib/utils/outlet.ts#L99

And this all ends up being the single tag of the root outlet: /packages/@ember/-internals/glimmer/lib/utils/outlet.ts#L71

Which appears to get updated for every transition to a given route, regardless of whether or not the model hook has actually rerun. I’m not super familiar with the router code so I’m getting lost at this point, but I know the update to the reference happens here, and I bet if we followed the thread we’d find this gets updated on every transition.

3 Likes

Ah, that takes us back to an area I do know about. Yes, the whole route state is basically an immutable data structure. If you transition in any way you’re going to invalidate it and get a rerender.

3 Likes

Thanks a ton, Chris, for finding this out.

In summary, all references to @model in templates are recomputed whenever the router transitions, correct?

1 Like

Correct, and it seems like according to @ef4 this is expected behavior.

1 Like