RouteInfo MetaData
Summary
The RFC introduces the ability to associate application specific metadata with its corresponding RouteInfo object. This also adds a metadata field to RouteInfo, which will be the return value of buildRouteInfoMetadata for its corresponding Route.
// app/route/profile.js
import Route from '@ember/routing/route';
import { inject } from '@ember/service';
export default Route.extend({
user: inject('user'),
buildRouteInfoMetadata() {
return {
trackingKey: 'page_profile',
user: {
id: this.user.id,
type: this.user.type
}
}
}
// ...
});
// app/services/analytics.js
import Service, { inject } from '@ember/service';
export default Service.extend({
router: inject('router'),
init() {
this._super(...arguments);
this.router.on('routeDidUpdate', (transition) => {
let { to, from } = transition;
let fromMeta = from.metadata;
let toMeta = to.metadata;
ga.sendEvent('pageView', {
from: fromMeta,
to: toMeta,
timestamp: Date.now(),
})
})
},
// ...
});
Motivation
While the RouteInfo object is sufficient in providing developers metadata about the Route itself, it is not sufficient in layering on application specific metadata about the Route. This metadata could be anything from a more domain-specific name for a Route, e.g. profile_page vs profile.index, all the way to providing contextual data when the Route was visited.
This metadata could be used for more pratical things like updating the document.title.
Currently, addons like Ember CLI Head and Ember CLI Document Title require the user to supply special metadata fields on your Route that will be used to update the title. This API would be a formalized place to place that metadata.
See the appendix for examples.
Detailed design
buildRouteInfoMetadata
This optional hook is intended to be used as a way of letting the routing system know about any metadata associated with the route.
Route Interface Extension
interface Route {
// ... existing public API
buildRouteInfoMetadata(): unknown
}
Runtime Semantics
- Always called before the
beforeModelhook is called - Maybe called more than once during a transition e.g. aborts, redirects.
RouteInfo.metadata
The metadata optional field on RouteInfo will be populated with the return value of buildRouteInfoMetadata. If there is no metadata associated with the Route, the metadata field will be null.
interface RouteInfo {
// ... existing public API
metadata: Maybe<unknown>;
}
This field will also be added to RouteInfoWithAttributes as it is just a super-set of RouteInfo.
How we teach this
We feel that this a low-level primitive that will allow existing tracking addons to encapsulate. That being said the concept here is pretty simple: What gets returned from buildRouteInfoMetadata becomes the value of RouteInfo.metadata for that Route.
The guides and tutorial should be updated to incorporate an example on how these APIs could integrate with services like Google Analytics.
Drawbacks
This adds an additional hook that is called during route activation, expanding the surface area of the Route class.
While this is true, there is currently no good way to associate application-specific metadata with a route transition.
Alternatives
There are numerous alternative to the proposal:
setRouteMetadata
This API would be similar to setComponentManager and setModifierManager. For example:
// app/route/profile.js
import Route, { setRouteMetadata } from '@ember/routing/route';
export default Route.extend({
init() {
this._super(...arguments);
setRouteMetadata(this, {
trackingKey: 'page_profile',
profile: {
viewing: this.userId,
locale: this.userLocale
}
});
}
// ...
});
You would then use the a RouteInfo to lookup the value:
// app/services/analytics.js
import { getRouteMetadata } from '@ember/routing/route';
import Service, { inject } from '@ember/service';
export default Service.extend({
router: inject('router'),
init() {
this._super(...arguments);
this.router.on('routeDidUpdate', (transition) => {
let { to, from } = transition;
let { trackingKey: fromKey } = getRouteMetadata(from);
let { trackingKey: toKey } = getRouteMetadata(to);
ga.sendEvent('pageView', {
from: fromKey,
to: toKey,
timestamp: Date.now(),
})
})
},
// ...
});
This could work but there are two things that are confusing here:
- What happens if you call
setRouteMetadatamutliple times. Do you clobber the existing metadata? Do you merge it? - It is very odd that you would use a
RouteInfoto access the metadata when you set it on theRoute.
Route.metadata
This would add a special field to the Route class that would be copied off on to the RouteInfo. For example:
// app/route/profile.js
import Route, { setRouteMetadata } from '@ember/routing/route';
export default Route.extend({
metadata: {
trackingKey: 'page_profile',
profile: {
viewing: this.userId,
locale: this.userLocale
}
}
// ...
});
The value would then be populated on RouteInfo.metadata.
// app/services/analytics.js
import { getRouteMetadata } from '@ember/routing/route';
import Service, { inject } from '@ember/service';
export default Service.extend({
router: inject('router'),
init() {
this._super(...arguments);
this.router.on('routeDidUpdate', (transition) => {
let { to, from } = transition;
let fromMeta = from.metadata;
let toMeta = to.metadata;
ga.sendEvent('pageView', {
from: fromKey,
to: toKey,
timestamp: Date.now(),
})
})
},
// ...
});
This could work but there are two things that are problematic here:
- What happens to the this data if you subclass it? Do you merge or clobber the field?
- This is a generic property name and may conflict in existing applications
Return Metadata From activate
Today activate does not get called when the dynamic segments of the Route change, making it not well fit for this use case.
Unresolved questions
TBD?
Apendix A
Tracking example
// app/route/profile.js
import Route from '@ember/routing/route';
import { inject } from '@ember/service';
export default Route.extend({
user: inject('user'),
buildRouteInfoMetadata() {
return {
trackingKey: 'page_profile',
user: {
id: this.user.id,
type: this.user.type
}
}
}
// ...
});
// app/services/analytics.js
import Service, { inject } from '@ember/service';
export default Service.extend({
router: inject('router'),
init() {
this._super(...arguments);
this.router.on('routeDidUpdate', (transition) => {
let { to, from } = transition;
let fromMeta = from.metadata;
let toMeta = to.metadata;
ga.sendEvent('pageView', {
from: fromMeta,
to: toMeta,
timestamp: Date.now(),
})
})
},
// ...
});
Appendix B
Updating document.title
// app/route/profile.js
import Route from '@ember/routing/route';
import { inject } from '@ember/service';
export default Route.extend({
user: inject('user'),
buildRouteInfoMetadata() {
return {
title: 'My Cool WebPage'
}
}
// ...
});
// app/router.js
import Router from '@ember/routing/router';
// ...
export default Router.extend({
init() {
this._super(...arguments);
this.on('routeDidUpdate', (transition) => {
let { title } = transition.metadata;
document.title = title;
});
},
// ...
});