Warning about losing unsaved changes in Ember Octane

06 February 2020

I recently held an Ember Octane training and while preparing for it, I implemented a pattern that I thought worthwhile to share.

The all-powerful router service

One of the most underappreciated (is that a word?) pieces of Ember's architecture, in my opinion, is the router service. While working on different things, I've found myself reaching for it and finding it perfectly fit my needs. One such thing is route events, routeDidChange and routeWillChange that are fired on the service each time a transition is complete or about to begin.

You can handle these events on the route, too, but just as with other router-related features (like the transitionTo method), I reckon using the router service makes it easier to teach and remember. transitionTo is a great example: you should call it as this.transitionTo from routes, this.transitionToRoute from controllers and you can't call it in any way from components. Injecting the router service and calling this.router.transitionTo is a uniform way to launch a transition from everywhere.

Are you sure you want to lose your changes?

The particular example we'll look at is navigating away from a page that contained unsaved changes – starting to type out the name of a new band and clicking a link before saving. That that'd destroy the component that contained the name and is likely something we'd like to warn the user about. The routeWillChange event gives us the possibility to call off the transition. Let's see how.

A very basic form would look on screen as follows:

New band form

And would have the following source:

 1// app/components/new-band-input.js
 2import Component from '@glimmer/component';
 3import { action } from '@ember/object';
 4import { tracked } from '@glimmer/tracking';
 5
 6export default class NewBandInputComponent extends Component {
 7  @tracked name = '';
 8
 9  get isNameEmpty() {
10    return this.name === '';
11  }
12
13  @action
14  cancel() {
15    this.router.transitionTo('bands');
16  }
17
18  @action
19  updateName(event) {
20    this.name = event.target.value;
21  }
22}
 1{{!-- app/templates/new-band-input.hbs --}}
 2<div class="w-full flex" ...attributes>
 3  <input
 4    type="text"
 5    class="..."
 6    value={{this.name}}
 7    {{on "input" this.updateName}}
 8  />
 9  <button
10    type="button"
11    class="..."
12    disabled={{this.isNameEmpty}}
13    {{on "click" (fn @onAdd this.name)}}
14  >
15    Add
16  </button>
17  <button
18    type="button"
19    class="..."
20    {{on "click" this.cancel}}
21  >
22    Cancel
23  </button>
24</div>

At this point nothing prevents losing unsaved changes when the user leaves before hitting the Add button.

Adding the guard

Let's now add the guarding code, leveraging the willRouteChange event.

 1// app/components/new-band-input.js
 2+ import { inject as service } from '@ember/service';
 3
 4export default class NewBandInputComponent extends Component {
 5  @tracked name = '';
 6+  @service router;
 7
 8+  constructor() {
 9+    super(...arguments);
10+    this.addExitHandler();
11+  }
12
13  get isNameEmpty() {
14    return this.name === '';
15  }
16
17+  addExitHandler() {
18+    this.router.on('routeWillChange', this, this.confirm);
19+  }
20+
21+  removeExitHandler() {
22+    this.router.off('routeWillChange', this, this.confirm);
23+  }
24+
25+  confirm(transition) {
26+    if (this.name) {
27+      this.confirmExit(transition);
28+    }
29+  }
30+
31+  confirmExit(transition) {
32+    let leave = window.confirm('You have unsaved changes. Are you sure?');
33+    if (!leave) {
34+      transition.abort();
35+    }
36+  }
37+
38+  willDestroy() {
39+    this.removeExitHandler();
40+  }
41
42  @action
43  cancel() {
44    this.router.transitionTo('bands');
45  }
46
47  @action
48  updateName(event) {
49    this.name = event.target.value;
50  }
51}

We add a route event listener in addExitHandler. We have to make sure we also tear down the event listener when the component is no longer rendered on screen, so we have to call removeExitHandler in the right handler. We also have to make sure we're calling the exact same function (with the same this) when deregistering the event listener. Extracting the function to the confirm method makes this possible.

In confirmExit, we throw up the ugly, default confirm dialog that asks the user to confirm their intent to leave. If they realize they don't want to lose their changes, we stop the transition from happening so we stay on the same page. The willDestroy hook is the classic way to de-register event listeners and even Glimmer components have it.

Getting the timing of deregistering our event listener correctly

That works but there's a slight problem. Clicking the Add button calls the passed in onAdd function which saves the band on the back-end and then transitions to a new route. That means that our event listener fires once more and brings up the dialog – which is not something we want:

Over-eager event listener

A possible way to work around that is to remove the event listener when the Add button is clicked and before the passed in onAdd is called. That's a small change:

 1{{!-- app/templates/new-band-input.hbs --}}
 2<div class="w-full flex" ...attributes>
 3  {{!-- ... --}}
 4  <button
 5    type="button"
 6    class="..."
 7    disabled={{this.isNameEmpty}}
 8-    {{on "click" (fn @onAdd this.name)}}
 9+    {{on "click" this.add}}
10  >
11    Add
12  </button>
13  <button
14    type="button"
15    class="..."
16    {{on "click" this.cancel}}
17  >
18    Cancel
19  </button>
20</div>
 1// app/components/new-band-input.js
 2export default class NewBandInputComponent extends Component {
 3  // ...
 4+  @action
 5+  add() {
 6+    this.removeExitHandler();
 7+    this.args.onAdd(this.name);
 8+  }
 9
10  @action
11  cancel() {
12    this.router.transitionTo('bands');
13  }
14}

Remove event listeners once – and exactly once

That works but we now have to make sure we don't attempt to remove the same event listener twice – both from add and willDestroy. Trying to do makes Ember unhappy and scold us.

 1// app/components/new-band-input.js
 2export default class NewBandInputComponent extends Component {
 3  @tracked name = '';
 4  @service router;
 5  hasRemovedExitHandler = false;
 6
 7  // (...)
 8  removeExitHandler() {
 9+    if (!this.hasRemovedExitHandler) {
10+      this.router.off('routeWillChange', this, this.confirm);
11+      this.hasRemovedExitHandler = true;
12+    }
13  }
14}

All right, hang on, we're almost there.

Clicking around and trying different scenarios reveals one more error. If we hit Cancel on the modal dialog, indicating we want to stay and not lose our changes, our app will bring up the dialog again and again until we give up, close the tab, and go for a walk.

Over-eager event listener

A canceled transition is also a transition

Turns out that if we call transition.abort on a transition, that transition will be re-fired and our event handler duly catches it. That's what makes the infinite loop infinite (and a loop) so we should prevent that from happening. Happily, that's as simple as checking if the transition is aborted and only reacting to non-aborted ones:

 1// app/components/new-band-input.js
 2export default class NewBandInputComponent extends Component {
 3  @tracked name = '';
 4  @service router;
 5  // (...)
 6  confirm(transition) {
 7+    if (transition.isAborted) {
 8+      return;
 9+    }
10
11    if (this.name) {
12      this.confirmExit(transition);
13    }
14  }
15  // (...)
16}

With that final fix, we have a fully working solution.

Working solution

Share on Twitter