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:
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}
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:
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:
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.
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.
Share on Twitter