35 | Using Google's RAIL model,
36 | we learn to focus on the more critical aspects of a page or component
37 | in order to improve the user's perception application speed. We define
38 | Time to Interactivity to be the time it takes for the user to perceive
39 | that the application is ready for interaction.
40 |
41 | Ember Interactivity allows us to generate latency metrics tailored to this definition;
42 | specifically, by identifying the critical components required to render a parent
43 | route or component, we can track load times and identify bottlenecks that are
44 | critical to the user experience. By focusing on perceived load times, we are
45 | able to reduce user bounce rates and churn through making the content appear to
46 | load faster. Some strategies for this involve adding placeholders for necessarily
47 | long content wait times, but often there is plenty of low-hanging fruit to make
48 | actual improvements if we have the proper instrumentation to locate these issues.
49 |
50 |
51 | Try
52 | Performance Profiling
53 | in Chrome DevTools to see the route & components below show up under "User Timing":
54 |
55 |
56 |
57 |
58 |
59 |
60 | {{parent-interactivity}}
61 |
--------------------------------------------------------------------------------
/tests/unit/mixins/component-interactivity-test.js:
--------------------------------------------------------------------------------
1 | import { module } from 'qunit';
2 | import { setupTest } from 'ember-qunit';
3 | import test from 'ember-sinon-qunit/test-support/test';
4 | import RSVP from 'rsvp';
5 | import EmberObject from '@ember/object';
6 | import Service from '@ember/service';
7 | import { setOwner } from '@ember/application';
8 | import { sendEvent as send } from '@ember/object/events';
9 | import ComponentInteractivityMixin from 'ember-interactivity/mixins/component-interactivity';
10 | import MockInteractivityTrackingService from 'ember-interactivity/test-support/mock-interactivity-tracking-service';
11 |
12 | const COMPONENT_NAME = 'foo-bar';
13 |
14 | const InteractivityStub = Service.extend({
15 | didReporterBecomeInteractive() {},
16 | didReporterBecomeNonInteractive() {},
17 | subscribeComponent() {},
18 | unsubscribeComponent() {}
19 | });
20 |
21 | module('Unit | Mixin | component interactivity', function (hooks) {
22 | setupTest(hooks);
23 |
24 | hooks.beforeEach(function () {
25 | this.BaseObject = EmberObject.extend(ComponentInteractivityMixin, {
26 | interactivity: InteractivityStub.create(),
27 | interactivityTracking: MockInteractivityTrackingService.create(),
28 | toString() {
29 | return ``;
30 | }
31 | });
32 | });
33 |
34 | test('_latencyReportingName', function (assert) {
35 | assert.expect(1);
36 | let subject = this.BaseObject.create();
37 | setOwner(subject, this.owner);
38 | let id = subject.get('_latencyReportingName');
39 |
40 | assert.equal(id, COMPONENT_NAME, 'latencyReportingName pulls the component name from toString');
41 | });
42 |
43 | test('didReporterBecomeInteractive fires when reportInteractive is called', function (assert) {
44 | assert.expect(2);
45 | let subject = this.BaseObject.create();
46 | setOwner(subject, this.owner);
47 | let interactivity = subject.get('interactivity');
48 |
49 | let stub = this.stub(interactivity, 'didReporterBecomeInteractive');
50 |
51 | subject.reportInteractive();
52 |
53 | assert.ok(stub.calledOnce, 'didReporterBecomeInteractive was called on the interactivity service');
54 | assert.ok(stub.calledWithExactly(subject), 'the component was sent to interactivity service');
55 | });
56 |
57 | test('didReporterBecomeNonInteractive fires when reportNonInteractive is called', function (assert) {
58 | assert.expect(2);
59 | let subject = this.BaseObject.create();
60 | setOwner(subject, this.owner);
61 | let interactivity = subject.get('interactivity');
62 |
63 | let stub = this.stub(interactivity, 'didReporterBecomeNonInteractive');
64 |
65 | subject.reportNonInteractive();
66 |
67 | assert.ok(stub.calledOnce, 'didReporterBecomeNonInteractive was called on the interactivity service');
68 | assert.ok(stub.calledWithExactly(subject), 'the component was sent to interactivity service');
69 | });
70 |
71 | test('didReporterBecomeNonInteractive fires automatically on willDestroyElement', function (assert) {
72 | assert.expect(2);
73 | let subject = this.BaseObject.create();
74 | setOwner(subject, this.owner);
75 | let interactivity = subject.get('interactivity');
76 |
77 | let stub = this.stub(interactivity, 'didReporterBecomeNonInteractive');
78 |
79 | send(subject, 'willDestroyElement');
80 |
81 | assert.ok(stub.calledOnce, 'didReporterBecomeNonInteractive was called on the interactivity service');
82 | assert.ok(stub.calledWithExactly(subject), 'the component was sent to interactivity service');
83 | });
84 |
85 | test('monitors child components if isInteractive is defined', function (assert) {
86 | assert.expect(1);
87 | let subject = this.BaseObject.create({ isInteractive() {} });
88 | setOwner(subject, this.owner);
89 | let interactivity = subject.get('interactivity');
90 | let promise = RSVP.Promise.resolve(null, 'test subscribeComponent promise');
91 | let stub = this.stub(interactivity, 'subscribeComponent').returns(promise);
92 |
93 | subject.willInsertElement();
94 | assert.ok(stub.calledOnce, 'the component invokes interactivity.subscribeComponent');
95 | });
96 |
97 | test('stops monitoring when it is destroyed', function (assert) {
98 | assert.expect(1);
99 | let subject = this.BaseObject.create({ isInteractive() {} });
100 | setOwner(subject, this.owner);
101 | let interactivity = subject.get('interactivity');
102 | let stub = this.stub(interactivity, 'unsubscribeComponent');
103 |
104 | subject.willDestroyElement();
105 | assert.ok(stub.calledOnce, 'the component invokes interactivity.unsubscribeComponent');
106 | });
107 | });
108 |
--------------------------------------------------------------------------------
/tests/unit/services/interactivity-test.js:
--------------------------------------------------------------------------------
1 | import { module } from 'qunit';
2 | import { setupTest } from 'ember-qunit';
3 | import test from 'ember-sinon-qunit/test-support/test';
4 | import EmberObject from '@ember/object';
5 |
6 | let service;
7 |
8 | module('Unit | Service | interactivity', function (hooks) {
9 | setupTest(hooks);
10 |
11 | hooks.beforeEach(function () {
12 | service = this.owner.lookup('service:interactivity');
13 | });
14 |
15 | test('tracks interactive reporters', function (assert) {
16 | assert.expect(2);
17 |
18 | service.subscribeRoute({
19 | name: 'test.dummy.route.foo',
20 | isInteractive() {}
21 | });
22 |
23 | let reporterName = 'foo-bar';
24 |
25 | let mockReporter = EmberObject.create({
26 | _latencyReportingName: reporterName
27 | });
28 |
29 | service.didReporterBecomeInteractive(mockReporter);
30 | assert.equal(service._currentRouteSubscriber._reporters[reporterName], 1, 'registered that the reporter is interactive');
31 | service.didReporterBecomeNonInteractive(mockReporter);
32 | assert.equal(service._currentRouteSubscriber._reporters[reporterName], 0, 'registered that the reporter is no longer interactive');
33 | });
34 |
35 | test('tracks counts for multiple instances of a reporter', function (assert) {
36 | assert.expect(3);
37 |
38 | service.subscribeRoute({
39 | name: 'test.dummy.route.foo',
40 | isInteractive() {}
41 | });
42 |
43 | let reporterName = 'foo-bar';
44 |
45 | let mockReporter = EmberObject.create({
46 | _latencyReportingName: reporterName
47 | });
48 |
49 | let mockReporter2 = EmberObject.create({
50 | _latencyReportingName: reporterName
51 | });
52 |
53 | service.didReporterBecomeInteractive(mockReporter);
54 | assert.equal(service._currentRouteSubscriber._reporters[reporterName], 1, 'registered that the reporter is interactive');
55 | service.didReporterBecomeInteractive(mockReporter2);
56 | assert.equal(service._currentRouteSubscriber._reporters[reporterName], 2, 'registered that 2 instances of the reporter are interactive');
57 | service.didReporterBecomeNonInteractive(mockReporter);
58 | assert.equal(service._currentRouteSubscriber._reporters[reporterName], 1, 'registered that a single instance is still interactive');
59 | });
60 |
61 | test('monitors for route interactivity criteria via isInteractive', function (assert) {
62 | assert.expect(2);
63 |
64 | let completed = false;
65 | let criticalComponents = ['foo-bar-1', 'foo-bar-2'];
66 | let options = {
67 | name: 'test.dummy.route.foo',
68 | isInteractive(didReportInteractive) {
69 | return criticalComponents.every((name) => {
70 | return didReportInteractive(name);
71 | });
72 | }
73 | };
74 |
75 | let mockReporter1 = EmberObject.create({
76 | _latencyReportingName: criticalComponents[0]
77 | });
78 |
79 | let mockReporter2 = EmberObject.create({
80 | _latencyReportingName: criticalComponents[1]
81 | });
82 |
83 | service.didReporterBecomeInteractive(mockReporter1);
84 | service.subscribeRoute(options).then(() => {
85 | completed = true;
86 | assert.ok(completed, 'reported interactive when all conditions were met');
87 | });
88 |
89 | assert.notOk(completed, 'did not report interactive before all conditions were met');
90 | service.didReporterBecomeInteractive(mockReporter2);
91 | });
92 |
93 | test('checks interactivity for the first parent subscriber', function (assert) {
94 | assert.expect(3);
95 | let mockSubscriber = EmberObject.create({
96 | _latencyReportingId: 'test-parent-subscriber-id',
97 |
98 | isInteractive(didReportInteractive) {
99 | return didReportInteractive('test-reporter-1');
100 | },
101 |
102 | parentView: {
103 | isInteractive() {
104 | throw new Error('Interactivity traversal should stop at the first subscriber parent');
105 | }
106 | }
107 | });
108 |
109 | let isInteractiveSpy = this.spy(mockSubscriber, 'isInteractive');
110 |
111 | let parentView = {
112 | parentView: {
113 | parentView: mockSubscriber
114 | }
115 | };
116 |
117 | let mockReporter = EmberObject.create({
118 | _latencyReportingName: 'test-reporter-1',
119 | parentView
120 | });
121 |
122 | service.subscribeComponent({
123 | id: mockSubscriber.toString(),
124 | isInteractive: mockSubscriber.isInteractive
125 | }).then(() => {
126 | assert.ok(isInteractiveSpy.calledTwice, 'called isInteractive twice');
127 | assert.ok(isInteractiveSpy.firstCall.returned(false), 'returned false on the first isInteractive check');
128 | assert.ok(isInteractiveSpy.secondCall.returned(true), 'returned true on the second isInteractive check');
129 | });
130 |
131 | service.didReporterBecomeInteractive(mockReporter);
132 | });
133 | });
134 |
135 |
136 |
--------------------------------------------------------------------------------
/addon/utils/interactivity-subscriber.js:
--------------------------------------------------------------------------------
1 | import { assert } from '@ember/debug';
2 | import RSVP from 'rsvp';
3 |
4 | /**
5 | * The base class for all interactivity subscribers
6 | *
7 | * @class InteractivitySubscriber
8 | */
9 | class InteractivitySubscriber {
10 | /**
11 | * Creates the InteractivitySubscriber
12 | *
13 | * @method constructor
14 | *
15 | * @param {object} options - Single configuration parameter that expects the following attributes:
16 | * {string} name - The name of the subscriber (used in testing) // TODO: Still needed?
17 | * {function} isInteractive - Method for checking interactivity conditions as reports come in
18 | */
19 | constructor({ name, isInteractive } = {}) {
20 | this.name = name;
21 | this._isInteractive = isInteractive;
22 | this._reporters = {};
23 | this._didReportInteractive = this._didReportInteractive.bind(this);
24 | }
25 |
26 | /**
27 | * Creates a promise to use for resolving interactivity conditions
28 | *
29 | * @method createPromise
30 | */
31 | createPromise() {
32 | this.promise = new RSVP.Promise((resolve, reject) => {
33 | this.resolve = resolve;
34 | this.reject = reject;
35 | });
36 | }
37 |
38 | /**
39 | * Checks if the subscriber is now interactive. If so, it resolves the pending promise.
40 | */
41 | checkInteractivity() {
42 | if (this._isInteractive(this._didReportInteractive)) {
43 | this.resolve();
44 | }
45 | }
46 |
47 | /**
48 | * Marks a child reporter as interactive.
49 | * Updates a count of how many instances of this component are currently interactive.
50 | *
51 | * @method childBecameInteractive
52 | * @param {Ember.Component} reporter - The child component
53 | */
54 | childBecameInteractive(reporter) {
55 | let latencyReportingName = reporter.get('_latencyReportingName');
56 |
57 | if (!this._reporters[latencyReportingName]) {
58 | this._reporters[latencyReportingName] = 1;
59 | } else {
60 | this._reporters[latencyReportingName]++;
61 | }
62 | }
63 |
64 | /**
65 | * Marks a child reporter as non-interactive.
66 | * Updates a count of how many instances of this component are currently interactive.
67 | *
68 | * @method childBecameNonInteractive
69 | * @param {Ember.Component} reporter - The child component
70 | */
71 | childBecameNonInteractive(reporter) {
72 | let latencyReportingName = reporter.get('_latencyReportingName');
73 |
74 | if (this._reporters[latencyReportingName]) {
75 | this._reporters[latencyReportingName]--;
76 | }
77 | }
78 |
79 | /**
80 | * Check to see if a particular reporter is interactive
81 | *
82 | * @method _didReportInteractive
83 | * @private
84 | *
85 | * @param {string} name - Name of a reporter
86 | * @param {object} options - Options for modifying the check
87 | * {number} count - If provided, expects a reporter to have become interactive exactly this many times
88 | * @return {boolean} - Whether or not the reporter is currently interactive
89 | */
90 | _didReportInteractive(name, options) {
91 | if (options && options.count) {
92 | return this._reporters[name] === options.count;
93 | }
94 |
95 | return !!this._reporters[name];
96 | }
97 | }
98 |
99 | /**
100 | * Extends InteractivitySubscriber with component-specific functionality
101 | *
102 | * @class ComponentInteractivitySubscriber
103 | */
104 | export class ComponentInteractivitySubscriber extends InteractivitySubscriber {
105 | /**
106 | * Creates the ComponentInteractivitySubscriber
107 | *
108 | * @method constructor
109 | *
110 | * @param {object} options - Single configuration parameter that expects the following attributes:
111 | * {string} id - The id of the subscriber // TODO: Is this needed?
112 | * {function} isInteractive - Method for checking interactivity conditions as reports come in
113 | */
114 | constructor({ id, isInteractive }) {
115 | assert('Every subscriber must provide an isInteractive method', typeof(isInteractive) === 'function');
116 | super(...arguments);
117 | this.id = id;
118 | this.createPromise();
119 |
120 | // If the subscriber is already interactive, we should resolve immediately.
121 | this.checkInteractivity();
122 | }
123 | }
124 |
125 | /**
126 | * Extends InteractivitySubscriber with route-specific functionality
127 | *
128 | * @class RouteInteractivitySubscriber
129 | */
130 | export class RouteInteractivitySubscriber extends InteractivitySubscriber {
131 | /**
132 | * Creates the RouteInteractivitySubscriber
133 | *
134 | * @method constructor
135 | */
136 | constructor() {
137 | super(...arguments);
138 | this.isActive = false;
139 | }
140 |
141 | /**
142 | * Make this the active route subscriber
143 | *
144 | * @method subscribe
145 | *
146 | * @param {object} options - Single configuration parameter that expects the following attributes:
147 | * {string} name - The name of the subscriber (used in testing) // TODO: Still needed?
148 | * {function} isInteractive - Method for checking interactivity conditions as reports come in
149 | */
150 | subscribe({ name, isInteractive }) {
151 | this.isActive = true;
152 | this.name = name;
153 | this._isInteractive = isInteractive;
154 | this.createPromise();
155 | }
156 |
157 | /**
158 | * Unsubscribe this route
159 | *
160 | * @method unsubscribe
161 | */
162 | unsubscribe() {
163 | this.isActive = false;
164 | this.name = null;
165 | this.promise = null;
166 | this.resolve = null;
167 | this.reject = null;
168 | }
169 |
170 | /**
171 | * Check interactivity if this is the active route
172 | *
173 | * @method checkInteractivity
174 | */
175 | checkInteractivity() {
176 | if (!this.isActive) {
177 | return;
178 | }
179 |
180 | super.checkInteractivity();
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/addon/services/interactivity.js:
--------------------------------------------------------------------------------
1 | import { bind } from '@ember/runloop';
2 | import Service from '@ember/service';
3 | import {
4 | getLatencySubscriptionId,
5 | getLatencyReportingName
6 | } from 'ember-interactivity/utils/interactivity';
7 | import {
8 | RouteInteractivitySubscriber,
9 | ComponentInteractivitySubscriber
10 | } from 'ember-interactivity/utils/interactivity-subscriber';
11 |
12 | /**
13 | * This service keeps track of all rendered components that have reported that they
14 | * are ready for user interaction. This service also allows a Route to monitor for a
15 | * custom interactivity condition to be met.
16 | *
17 | * Use with these mixins: component-interactivity & route-interactivity
18 | */
19 | export default Service.extend({
20 | /**
21 | * The current route being tracked for interactivity
22 | */
23 | _currentRouteSubscriber: null,
24 |
25 | /**
26 | * Components that rely on their children to report interactivity
27 | */
28 | _componentSubscribers: null,
29 |
30 | /**
31 | * Setup private variables
32 | *
33 | * @method init
34 | */
35 | init() {
36 | this._super(...arguments);
37 |
38 | this._componentSubscribers = {};
39 | this._currentRouteSubscriber = new RouteInteractivitySubscriber();
40 | },
41 |
42 | /**
43 | * Track a component's latency. When components become interactive or non-interactive, check the component's
44 | * `isInteractive()` to determine if the component is deemed "ready for user interaction".
45 | *
46 | * @method subscribeComponent
47 | *
48 | * @param {object} options - Single configuration parameter that expects the following attributes:
49 | * {string} id - Unique component id
50 | * {function} isInteractive - Method for checking interactivity conditions as reports come in
51 | * @return {RSVP.Promise} Resolves when interactivity conditions are met
52 | */
53 | subscribeComponent({ id, isInteractive }) {
54 | let subscriber = new ComponentInteractivitySubscriber({
55 | id,
56 | isInteractive
57 | });
58 |
59 | this._componentSubscribers[id] = subscriber;
60 | return subscriber.promise;
61 | },
62 |
63 | /**
64 | * Unsubscribe the component from latency tracking. This is used for teardown.
65 | *
66 | * @method unsubscribeComponent
67 | */
68 | unsubscribeComponent(subscriberId) {
69 | this._componentSubscribers[subscriberId] = null;
70 | },
71 |
72 | /**
73 | * Track a route's latency. When components become interactive or non-interactive, check the route's
74 | * `isInteractive()` to determine if the route is deemed "ready for user interaction". Only one route should be tracked at a time.
75 | *
76 | * @method subscribeRoute
77 | *
78 | * @param {object} options - Single configuration parameter that expects the following attributes:
79 | * {string} name - The name of the subscriber (used in testing) // TODO: Still needed?
80 | * {function} isInteractive - Method for checking interactivity conditions as reports come in
81 | * @return {RSVP.Promise} Resolves when interactivity conditions are met
82 | */
83 | subscribeRoute(options) {
84 | this.unsubscribeRoute();
85 | this._currentRouteSubscriber.subscribe(options);
86 | this._currentRouteSubscriber.checkInteractivity();
87 | return this._currentRouteSubscriber.promise.then(bind(this, this.unsubscribeRoute));
88 | },
89 |
90 | /**
91 | * Unsubscribe the current route from latency tracking. This is used for teardown.
92 | *
93 | * @method unsubscribeRoute
94 | */
95 | unsubscribeRoute() {
96 | this._currentRouteSubscriber.unsubscribe();
97 | },
98 |
99 | /**
100 | * Find the correct parent subscriber for the given component
101 | *
102 | * @method subscriberFor
103 | *
104 | * @param {Ember.Component} reporter - The component reporting interactivity
105 | * @return {ComponentInteractivitySubscriber|RouteInteractivitySubscriber} The parent subscriber
106 | */
107 | subscriberFor(reporter) {
108 | let componentSubscriber = this._findParentSubscriber(reporter);
109 |
110 | if (componentSubscriber) {
111 | return componentSubscriber;
112 | }
113 |
114 | return this._currentRouteSubscriber;
115 | },
116 |
117 | /**
118 | * Notify the service that a reporter became interactive.
119 | * Checks the appropriate subscriber for interactivity conditions.
120 | *
121 | * @method didReporterBecomeInteractive
122 | *
123 | * @param {Ember.Component} reporter - The component that is now interactive
124 | */
125 | didReporterBecomeInteractive(reporter) {
126 | let subscriber = this.subscriberFor(reporter);
127 | subscriber.childBecameInteractive(reporter);
128 | subscriber.checkInteractivity();
129 | },
130 |
131 | /**
132 | * Notify the service that a reporter became non-interactive.
133 | * Checks the appropriate subscriber for interactivity conditions.
134 | *
135 | * @method didReporterBecomeNonInteractive
136 | *
137 | * @param {Ember.Component} reporter - The component that is no longer interactive
138 | */
139 | didReporterBecomeNonInteractive(reporter) {
140 | let subscriber = this.subscriberFor(reporter);
141 | subscriber.childBecameNonInteractive(reporter);
142 | subscriber.checkInteractivity();
143 | },
144 |
145 | /**
146 | * Finds whether a parent of this component is subscribed to the interactivity service
147 | *
148 | * @method _findParentSubscriber
149 | * @private
150 | *
151 | * @param {Ember.Component} child - The child component
152 | * @return {ComponentInteractivitySubscriber|undefined} The parent subscriber, if it exists
153 | */
154 | _findParentSubscriber(child) {
155 | let parentId, parentName;
156 |
157 | while (parentName !== 'application-wrapper' && child.parentView) {
158 | parentId = getLatencySubscriptionId(child.parentView);
159 | if (this._componentSubscribers[parentId]) {
160 | return this._componentSubscribers[parentId];
161 | }
162 | parentName = getLatencyReportingName(child.parentView);
163 | child = child.parentView;
164 | }
165 | }
166 | });
167 |
--------------------------------------------------------------------------------
/addon/mixins/component-interactivity.js:
--------------------------------------------------------------------------------
1 | import Ember from 'ember';
2 | import { assert } from '@ember/debug';
3 | import { computed } from '@ember/object';
4 | import { on } from '@ember/object/evented';
5 | import Mixin from '@ember/object/mixin';
6 | import { assign } from '@ember/polyfills';
7 | import { bind } from '@ember/runloop';
8 | import { inject as injectService } from '@ember/service';
9 | import IsFastbootMixin from 'ember-is-fastboot/mixins/is-fastboot';
10 | import getConfig from 'ember-interactivity/utils/config';
11 | import { getTimeAsFloat } from 'ember-interactivity/utils/date';
12 | import {
13 | getLatencySubscriptionId,
14 | getLatencyReportingName
15 | } from 'ember-interactivity/utils/interactivity';
16 | import { INITIALIZING_LABEL, INTERACTIVE_LABEL, markTimeline } from 'ember-interactivity/utils/timeline-marking';
17 |
18 | /**
19 | * For components that should inform the interactivity service that they are now ready for user interaction.
20 | *
21 | * In your component, you MUST call `reportInteractive` or define `isInteractive`.
22 | */
23 | export default Mixin.create(IsFastbootMixin, {
24 | interactivity: injectService(),
25 | interactivityTracking: injectService(),
26 |
27 | /**
28 | * A component may implement the method isInteractive, which returns true if all conditions for interactivity have been met
29 | *
30 | * If isInteractive is defined, it is used to see if conditions are met and then fires the interactive event.
31 | * If isInteractive is not defined, the developer must call `reportInteractive` manually.
32 | *
33 | * @method isInteractive
34 | * @param {function} didReportInteractive - Method that takes a reporter name and returns whether it is interactive
35 | * @return {boolean} True if all interactivity conditions have been met
36 | */
37 | isInteractive: null,
38 |
39 | /**
40 | * Subscribe component for interactivity tracking
41 | */
42 | willInsertElement() {
43 | this._super(...arguments);
44 |
45 | this._isInitializing();
46 | if (this._isSubscriber()) { // Component has implemented the `isInteractive` method
47 | this.get('interactivity').subscribeComponent({
48 | id: this.get('_latencySubscriptionId'),
49 | name: this.get('_latencyReportingName'),
50 | isInteractive: bind(this, this.isInteractive)
51 | }).then(bind(this, this._becameInteractive));
52 | }
53 | },
54 |
55 | /**
56 | * Unsubscribe component from interactivity tracking
57 | */
58 | willDestroyElement() {
59 | this._super(...arguments);
60 |
61 | if (this._isSubscriber()) {
62 | this.get('interactivity').unsubscribeComponent(this.get('_latencySubscriptionId'));
63 | }
64 | },
65 |
66 | /**
67 | * This method will notify the `interactivity` service that the component has
68 | * finished rendering and is now interactive for the user.
69 | *
70 | * Example:
71 | * interactiveAfterRendered: on('didInsertElement', function () {
72 | * scheduleOnce('afterRender', this, this.reportInteractive);
73 | * })
74 | *
75 | * @method reportInteractive
76 | */
77 | reportInteractive() {
78 | assert(`Do not invoke reportInteractive if isInteractive is defined: {{${this.get('_latencyReportingName')}}}`, !this._isSubscriber());
79 | this._becameInteractive();
80 | },
81 |
82 | /**
83 | * Call this method if the component is no longer interactive (e.g. reloading data)
84 | * Also executes by default during component teardown
85 | *
86 | * @method reportNonInteractive
87 | */
88 | reportNonInteractive: on('willDestroyElement', function () {
89 | this.get('interactivity').didReporterBecomeNonInteractive(this);
90 | }),
91 |
92 | /**
93 | * Human-readable component name
94 | * @private
95 | */
96 | _latencyReportingName: computed(function () {
97 | return getLatencyReportingName(this);
98 | }),
99 |
100 | /**
101 | * Unique component ID, useful for distinguishing multiple instances of the same component
102 | * @private
103 | */
104 | _latencySubscriptionId: computed(function () {
105 | return getLatencySubscriptionId(this);
106 | }),
107 |
108 | /**
109 | * Marks that the component has become interactive and sends a tracking event.
110 | * If enabled, adds the event to the performance timeline.
111 | *
112 | * @method _becameInteractive
113 | * @private
114 | */
115 | _becameInteractive() {
116 | let timestamp = getTimeAsFloat();
117 | this.get('interactivity').unsubscribeComponent(this.get('_latencySubscriptionId'));
118 | this._markTimeline(INTERACTIVE_LABEL);
119 |
120 | this._sendEvent('componentInteractive', {
121 | clientTime: timestamp,
122 | timeElapsed: timestamp - this._componentInitializingTimestamp
123 | });
124 |
125 | this.get('interactivity').didReporterBecomeInteractive(this);
126 | },
127 |
128 | /**
129 | * Marks that the component has begun rendering.
130 | * If enabled, adds the event to the performance timeline.
131 | *
132 | * @method _isInitializing
133 | * @private
134 | */
135 | _isInitializing() {
136 | this._componentInitializingTimestamp = getTimeAsFloat();
137 | this._markTimeline(INITIALIZING_LABEL);
138 | this._sendEvent('componentInitializing', { clientTime: this._componentInitializingTimestamp });
139 | },
140 |
141 | /**
142 | * Determines whether this component is a subscriber (relies on instrumented child components)
143 | *
144 | * @method _isSubscriber
145 | * @private
146 | *
147 | * @return {boolean} Subscriber status
148 | */
149 | _isSubscriber() {
150 | return !!this.isInteractive;
151 | },
152 |
153 | /**
154 | * Creates a unique label for use in the performance timeline
155 | *
156 | * @method _getTimelineLabel
157 | * @private
158 | *
159 | * @param {string} type - The type of label being created
160 | * @return {string} The timeline label
161 | */
162 | _getTimelineLabel(type) { // BUG: Components that have "component" in their name will not have a unique label, due to the parsing logic below
163 | let latencyId = this.get('_latencySubscriptionId').split('component:')[1].slice(0, -1); // Make the component name more readable but still unique
164 | return `Component ${type}: ${latencyId}`;
165 | },
166 |
167 | /**
168 | * Marks the performance timeline with component latency events
169 | *
170 | * @method _markTimeline
171 | * @private
172 | *
173 | * @param {string} type - The event type
174 | */
175 | _markTimeline(type) {
176 | if(Ember.testing || this.get('_isFastBoot') || this._isFeaturedDisabled('timelineMarking')) {
177 | return;
178 | }
179 |
180 | markTimeline(type, bind(this, this._getTimelineLabel));
181 | },
182 |
183 | /**
184 | * Sends tracking information for the component's interactivity
185 | *
186 | * @method _sendEvent
187 | * @private
188 | *
189 | * @param {string} name - Name of the event
190 | * @param {object} data - Data attributes for the event
191 | */
192 | _sendEvent(name, data = {}) {
193 | if (this.get('_isFastBoot') || this._isFeaturedDisabled('tracking')) {
194 | return;
195 | }
196 |
197 | this.get('interactivityTracking').trackComponent(assign({
198 | event: name,
199 | component: this.get('_latencyReportingName'),
200 | componentId: this.get('_latencySubscriptionId')
201 | }, data));
202 | },
203 |
204 | /**
205 | * Check to see if a feature has been disabled by the app config
206 | *
207 | * @method _isFeatureDisabled
208 | * @private
209 | *
210 | * @param {string} type - The name of the feature being checked
211 | * @return {boolean} - True if the feature is disabled
212 | */
213 | _isFeaturedDisabled(type) {
214 | let option = getConfig(this)[type];
215 | return option && (option.disableComponents || (option.disableLeafComponents && !this._isSubscriber()));
216 | }
217 | });
218 |
--------------------------------------------------------------------------------
/tests/unit/mixins/route-interactivity-test.js:
--------------------------------------------------------------------------------
1 | import { module } from 'qunit';
2 | import { setupTest } from 'ember-qunit';
3 | import test from 'ember-sinon-qunit/test-support/test';
4 | import { waitUntil } from '@ember/test-helpers';
5 | import RSVP from 'rsvp';
6 | import EmberObject from '@ember/object';
7 | import Service from '@ember/service';
8 | import { setOwner } from '@ember/application';
9 | import { run } from '@ember/runloop';
10 | import RouteInteractivityMixin from 'ember-interactivity/mixins/route-interactivity';
11 | import MockInteractivityTrackingService from 'ember-interactivity/test-support/mock-interactivity-tracking-service';
12 |
13 | const ROUTE_NAME = 'foo.bar';
14 | const CRITICAL_COMPONENTS = ['foo', 'bar'];
15 | let resolved;
16 | let resolve;
17 |
18 | const InteractivityStub = Service.extend({
19 | subscribeRoute() {
20 | return new RSVP.Promise((res) => {
21 | resolve = () => {
22 | res();
23 | resolved = true;
24 | };
25 | });
26 | },
27 |
28 | unsubscribeRoute() {}
29 | });
30 |
31 | const VisibilityStub = Service.extend({ lostVisibility: false });
32 |
33 | module('Unit | Mixin | route interactivity', function (hooks) {
34 | setupTest(hooks);
35 |
36 | hooks.beforeEach(function () {
37 | this.BaseObject = EmberObject.extend(RouteInteractivityMixin, {
38 | fullRouteName: ROUTE_NAME,
39 | criticalComponents: CRITICAL_COMPONENTS,
40 | documentVisibility: VisibilityStub.create(),
41 | interactivity: InteractivityStub.create(),
42 | interactivityTracking: MockInteractivityTrackingService.create(),
43 | });
44 | resolved = false;
45 | });
46 |
47 | test('_isLeafRoute - truthy', function (assert) {
48 | let transition = { targetName: ROUTE_NAME };
49 |
50 | let subject = this.BaseObject.create();
51 | setOwner(subject, this.owner);
52 | let isLeafRoute = subject._isLeafRoute(transition);
53 |
54 | assert.ok(isLeafRoute, 'correctly identifies leaf route');
55 | });
56 |
57 | test('_isLeafRoute - falsey', function (assert) {
58 | let transition = { targetName: 'stuff.things' };
59 |
60 | let subject = this.BaseObject.create();
61 | setOwner(subject, this.owner);
62 | let isLeafRoute = subject._isLeafRoute(transition);
63 |
64 | assert.notOk(isLeafRoute, 'correctly identifies non-leaf route');
65 | });
66 |
67 | test('_isLeafRoute - truthy w/ saved transition', function (assert) {
68 | let transition = { targetName: ROUTE_NAME };
69 |
70 | let subject = this.BaseObject.create({
71 | _latestTransition: transition
72 | });
73 | setOwner(subject, this.owner);
74 | let isLeafRoute = subject._isLeafRoute();
75 |
76 | assert.ok(isLeafRoute, 'correctly identifies leaf route');
77 | });
78 |
79 | test('_isLeafRoute - falsey w/ saved transition', function (assert) {
80 | let transition = { targetName: 'stuff.things' };
81 | let subject = this.BaseObject.create({
82 | _latestTransition: transition
83 | });
84 | setOwner(subject, this.owner);
85 |
86 | let isLeafRoute = subject._isLeafRoute();
87 |
88 | assert.notOk(isLeafRoute, 'correctly identifies non-leaf route');
89 | });
90 |
91 | test('_sendTransitionEvent', function (assert) {
92 | let transition = { targetName: ROUTE_NAME };
93 |
94 | let subject = this.BaseObject.create({
95 | _latestTransition: transition
96 | });
97 | setOwner(subject, this.owner);
98 |
99 | let phase = 'Yarrr';
100 | let targetName = 'Narf';
101 | let lostVisibility = subject.get('documentVisibility.lostVisibility');
102 | let additionalData = { foo: 'bar' };
103 |
104 | subject._sendTransitionEvent(phase, targetName, additionalData);
105 |
106 | assert.equal(subject.get('interactivityTracking._trackedRouteCalls').length, 1, 'tracking event was sent');
107 |
108 | let data = subject.get('interactivityTracking._trackedRouteCalls')[0];
109 | assert.equal(data.event, `route${phase}`, 'event name passed');
110 | assert.equal(data.destination, targetName, 'target name passed');
111 | assert.equal(data.routeName, ROUTE_NAME, 'route name passed');
112 | assert.equal(data.lostVisibility, lostVisibility, 'lost visibility status passed');
113 | assert.ok(data.clientTime, 'timestamp created');
114 | assert.equal(data.foo, additionalData.foo, 'additional data passed');
115 | });
116 |
117 | test('_monitorInteractivity', function (assert) {
118 | assert.expect(5);
119 |
120 | let subject = this.BaseObject.create({
121 | isInteractive() {}
122 | });
123 | setOwner(subject, this.owner);
124 | let interactivity = subject.get('interactivity');
125 |
126 | let spy = this.spy(interactivity, 'subscribeRoute');
127 |
128 | subject._monitorInteractivity();
129 |
130 | assert.ok(spy.calledOnce, 'subscribeRoute was called');
131 | assert.ok(subject.get('_monitoringInteractivity'), 'monitoring active');
132 |
133 | let { args } = spy.firstCall;
134 | assert.equal(typeof(args[0].isInteractive), 'function', 'isInteractive method passed');
135 |
136 | let stub = this.stub(subject, '_sendTransitionCompleteEvent');
137 |
138 | resolve();
139 |
140 | waitUntil(() => {
141 | return resolved;
142 | }).then(() => {
143 | assert.notOk(subject.get('_monitoringInteractivity'), 'monitoring inactive');
144 | assert.ok(stub.calledOnce, '_sendTransitionCompleteEvent called');
145 | });
146 | });
147 |
148 | test('_monitorInteractivity - not monitoring', function (assert) {
149 | assert.expect(1);
150 |
151 | let subject = this.BaseObject.create({
152 | isInteractive() {}
153 | });
154 | setOwner(subject, this.owner);
155 |
156 | subject._monitorInteractivity();
157 |
158 | let stub = this.stub(subject, '_sendTransitionCompleteEvent');
159 |
160 | subject.set('_monitoringInteractivity', false);
161 | resolve();
162 |
163 | waitUntil(() => {
164 | return resolved;
165 | }).then(() => {
166 | assert.notOk(stub.calledOnce, '_sendTransitionCompleteEvent not called if monitoring is inactive');
167 | });
168 | });
169 |
170 | test('didTransition - not leaf route', function (assert) {
171 | let subject = this.BaseObject.create();
172 | setOwner(subject, this.owner);
173 | let stub = this.stub(subject, '_isLeafRoute').callsFake(() => false);
174 |
175 | let result = subject.actions.didTransition.call(subject);
176 |
177 | assert.ok(stub.calledOnce, '_isLeafRoute was called');
178 | assert.ok(result, 'returns true unless _super is false');
179 | });
180 |
181 | test('didTransition - default', function (assert) {
182 | let stub = this.stub(run, 'scheduleOnce');
183 |
184 | let subject = this.BaseObject.create();
185 | setOwner(subject, this.owner);
186 | this.stub(subject, '_isLeafRoute').callsFake(() => true);
187 |
188 | subject.actions.didTransition.call(subject);
189 |
190 | assert.ok(stub.calledOnce, 'scheduleOnce was called');
191 | let { args } = stub.firstCall;
192 | assert.equal(args[0], 'afterRender', 'afterRender scheduled');
193 | assert.equal(args[1], subject, 'correct context passed');
194 | assert.equal(args[2], subject._sendTransitionCompleteEvent, 'correct method passed');
195 | });
196 |
197 | test('didTransition - interactivity', function (assert) {
198 | let subject = this.BaseObject.create({
199 | isInteractive() {}
200 | });
201 | setOwner(subject, this.owner);
202 | this.stub(subject, '_isLeafRoute').callsFake(() => true);
203 |
204 | let stub = this.stub(subject, '_monitorInteractivity');
205 |
206 | subject.actions.didTransition.call(subject);
207 |
208 | assert.ok(stub.calledOnce, '_monitorInteractivity was called');
209 | });
210 |
211 | test('_sendTransitionCompleteEvent', function (assert) {
212 | let subject = this.BaseObject.create();
213 | setOwner(subject, this.owner);
214 | let sendTransitionStub = this.stub(subject, '_sendTransitionEvent');
215 |
216 | subject._resetHasFirstTransitionCompleted();
217 |
218 | subject._sendTransitionCompleteEvent(1234);
219 | let additionalData1 = sendTransitionStub.firstCall.args[2];
220 | assert.equal(additionalData1.isAppLaunch, true, 'first complete transition marked as app launch');
221 | assert.ok(additionalData1.timeElapsed, 'first complete transition includes time elapsed');
222 |
223 | subject._sendTransitionCompleteEvent(1234);
224 | let additionalData2 = sendTransitionStub.secondCall.args[2];
225 | assert.equal(additionalData2.isAppLaunch, false, 'second complete transition not marked as app launch');
226 | assert.notOk(additionalData2.timeElapsed, 'second complete transition does not include time elapsed');
227 | });
228 | });
229 |
--------------------------------------------------------------------------------
/addon/mixins/route-interactivity.js:
--------------------------------------------------------------------------------
1 | import Ember from 'ember';
2 | import Mixin from '@ember/object/mixin';
3 | import { on } from '@ember/object/evented';
4 | import { assign } from '@ember/polyfills';
5 | import { run } from '@ember/runloop';
6 | import { inject as injectService } from '@ember/service';
7 | import IsFastbootMixin from 'ember-is-fastboot/mixins/is-fastboot';
8 | import getConfig from 'ember-interactivity/utils/config';
9 | import { getTimeAsFloat } from 'ember-interactivity/utils/date';
10 | import { INITIALIZING_LABEL, INTERACTIVE_LABEL, markTimeline } from 'ember-interactivity/utils/timeline-marking';
11 |
12 | let hasFirstTransitionCompleted = false;
13 |
14 | /**
15 | * Route Mixin route-interactivity (mix into Ember.Route class or individual routes)
16 | *
17 | * All routes should emit the following 3 transition events:
18 | * 1.) validate (i.e. we have begun validating that transition is possible by fetching relevant data, ie model hooks)
19 | * 2.) execute (i.e. we are executing the transition by activating the route and scheduling render tasks)
20 | * 3.) interactive (i.e. we have completed the transition and the route is now interactive)
21 | */
22 | export default Mixin.create(IsFastbootMixin, {
23 | interactivity: injectService(),
24 | interactivityTracking: injectService(),
25 | visibility: injectService(),
26 |
27 | /**
28 | * A route may implement the method isInteractive, which returns true if all conditions for interactivity have been met
29 | *
30 | * If isInteractive is defined, it is used to see if conditions are met and then fires the transition complete event.
31 | * If isInteractive is not defined, the transition complete event automatically fires in the afterRender queue.
32 | *
33 | * @method isInteractive
34 | * @param {function} didReportInteractive - Method that takes a reporter name and returns whether it is interactive
35 | * @return {boolean} True if all interactivity conditions have been met
36 | */
37 | isInteractive: null,
38 |
39 | /**
40 | * Property for storing the transition object to be accessed in
41 | * lifecycle hooks that do not have it passed in as a parameter
42 | * @private
43 | */
44 | _latestTransition: null,
45 |
46 | /**
47 | * True when monitoring is active; do not send events when false
48 | * @private
49 | */
50 | _monitoringInteractivity: false,
51 |
52 | /**
53 | * Capture the incoming transition and send an event for the validate phase of that transition
54 | *
55 | * @method beforeModel
56 | * @param {object} transition - http://emberjs.com/api/classes/Transition.html
57 | */
58 | beforeModel(transition) {
59 | this.set('_latestTransition', transition);
60 | this._sendTransitionEvent('Initializing', transition.targetName);
61 | this._markTimeline(INITIALIZING_LABEL);
62 | return this._super(...arguments);
63 | },
64 |
65 | /**
66 | * Initiate monitoring with the interactivity service and send events upon resolution
67 | *
68 | * @method _monitorInteractivity
69 | * @private
70 | */
71 | _monitorInteractivity() {
72 | let isInteractive = this.isInteractive ? run.bind(this, this.isInteractive) : null;
73 | let options = {
74 | isInteractive,
75 | name: this.get('fullRouteName')
76 | };
77 |
78 | this.set('_monitoringInteractivity', true);
79 | this.get('interactivity').subscribeRoute(options).then(() => {
80 | if (this.get('_monitoringInteractivity')) {
81 | this.set('_monitoringInteractivity', false);
82 | this._sendTransitionCompleteEvent();
83 | }
84 | }).catch((/* error */) => {
85 | if (this.isDestroyed) { return; }
86 | if (this.get('_monitoringInteractivity')) {
87 | this.set('_monitoringInteractivity', false);
88 | this.get('interactivityTracking').trackError(); // TODO: Add more information here
89 | }
90 | });
91 | },
92 |
93 | /**
94 | * Send data for transition event
95 | *
96 | * @method _sendTransitionEvent
97 | * @private
98 | *
99 | * @param {string} phase - The phase of the transition that this event tracks, used to construct the event name
100 | * @param {string} targetName - The destination route for the current transition
101 | * @param {object} data [Optional] - Data to send with the tracking event
102 | */
103 | _sendTransitionEvent(phase, targetName, data = {}) {
104 | if (this.get('_isFastBoot') || this._isFeaturedDisabled('tracking')) {
105 | return;
106 | }
107 |
108 | let baseData = {
109 | event: `route${phase}`,
110 | destination: targetName,
111 | routeName: this.get('fullRouteName'),
112 | lostVisibility: this.get('documentVisibility.lostVisibility'),
113 | clientTime: getTimeAsFloat()
114 | };
115 |
116 | this.get('interactivityTracking').trackRoute(assign(baseData, data));
117 | },
118 |
119 | /**
120 | * Send data for the "complete transition" event
121 | *
122 | * @method _sendTransitionCompleteEvent
123 | * @private
124 | */
125 | _sendTransitionCompleteEvent() {
126 | if (this.get('_isFastBoot')) {
127 | return;
128 | }
129 |
130 | let data;
131 | if (hasFirstTransitionCompleted) {
132 | data = {
133 | isAppLaunch: false
134 | };
135 | } else {
136 | let time = getTimeAsFloat();
137 | data = {
138 | isAppLaunch: true,
139 | timeElapsed: (time*1000) - performance.timing.fetchStart,
140 | clientTime: time
141 | };
142 | }
143 |
144 | let routeName = this.get('fullRouteName');
145 | this._markTimeline(INTERACTIVE_LABEL);
146 | this._sendTransitionEvent('Interactive', routeName, data);
147 | hasFirstTransitionCompleted = true;
148 | },
149 |
150 | /**
151 | * Send an event for the execute phase of a transition
152 | *
153 | * @method _sendTransitionExecuteEvent
154 | * @private
155 | */
156 | _sendTransitionExecuteEvent: on('activate', function () {
157 | let transition = this.get('_latestTransition');
158 | if (transition) {
159 | this._sendTransitionEvent('Activating', transition.targetName);
160 | }
161 | }),
162 |
163 | /**
164 | * Determine if this is the destination route for the transition (otherwise, it's a parent)
165 | *
166 | * @method _isLeafRoute
167 | * @private
168 | *
169 | * @param {object} transition - http://emberjs.com/api/classes/Transition.html
170 | * @return {boolean} True if this route is the target of the current transition
171 | */
172 | _isLeafRoute(transition = this.get('_latestTransition')) {
173 | return transition && transition.targetName === this.get('fullRouteName');
174 | },
175 |
176 | /**
177 | * Creates a unique label for use in the performance timeline
178 | *
179 | * @method _getTimelineLabel
180 | * @private
181 | *
182 | * @param {string} type - The type of label being created
183 | * @return {string} The timeline label
184 | */
185 | _getTimelineLabel(type) {
186 | return `Route ${type}: ${this.get('fullRouteName')}`;
187 | },
188 |
189 | /**
190 | * Marks the performance timeline with route latency events
191 | *
192 | * @method _markTimeline
193 | * @private
194 | *
195 | * @param {string} type - The event type
196 | */
197 | _markTimeline(type) {
198 | if(Ember.testing || this.get('_isFastBoot') || this._isFeaturedDisabled('timelineMarking')) {
199 | return;
200 | }
201 |
202 | markTimeline(type, run.bind(this, this._getTimelineLabel));
203 | },
204 |
205 | _isFeaturedDisabled(type) {
206 | let option = getConfig(this)[type];
207 | return option && (option.disableRoutes || (option.disableParentRoutes && !this._isLeafRoute()));
208 | },
209 |
210 | /**
211 | * Used only for testing, to reset internal variables
212 | *
213 | * @method _resetHasFirstTransitionCompleted
214 | * @private
215 | */
216 | _resetHasFirstTransitionCompleted() {
217 | hasFirstTransitionCompleted = false;
218 | },
219 |
220 | actions: {
221 | /**
222 | * Schedule interactivity tracking for leaf routes
223 | *
224 | * @method didTransition
225 | *
226 | * @return {boolean} Bubble the action unless a lower-order action stopped bubbling
227 | */
228 | didTransition() {
229 | if (this._isLeafRoute()) {
230 | if (typeof(this.isInteractive) === 'function') {
231 | this._monitorInteractivity();
232 | } else {
233 | run.scheduleOnce('afterRender', this, this._sendTransitionCompleteEvent);
234 | }
235 | }
236 |
237 | return this._super(...arguments) !== false; // Check explicitly for falsey value
238 | },
239 |
240 | /**
241 | * Reset interactivity monitoring and fire an event if a new transition occurred before monitoring completed
242 | *
243 | * @method willTransition
244 | *
245 | * @return {boolean} Bubble the action unless a lower-order action stopped bubbling
246 | */
247 | willTransition() {
248 | if (this._isLeafRoute()) {
249 | if (this.get('_monitoringInteractivity')) {
250 | this.set('_monitoringInteractivity', false);
251 | this.get('interactivityTracking').trackError(); // User transitioned away from this route before completion (TODO: should this be an error?)
252 | }
253 | this.get('interactivity').unsubscribeRoute();
254 | }
255 |
256 | return this._super(...arguments) !== false; // Check explicitly for falsey value
257 | }
258 | }
259 | });
260 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | ==========================================
3 |
4 | [](https://app.netlify.com/sites/optimistic-booth-95742f/deploys)
5 | [](https://travis-ci.org/elwayman02/ember-interactivity)
6 | [](https://emberobserver.com/addons/ember-interactivity)
7 | [](https://codeclimate.com/github/elwayman02/ember-interactivity)
8 | [](https://greenkeeper.io/)
9 |
10 | Using Google's [RAIL model](https://developers.google.com/web/fundamentals/performance/rail#load),
11 | we learn to focus on the more critical aspects of a page or component
12 | in order to improve the user's perception application speed. We define
13 | *Time to Interactivity* to be the time it takes for the user to perceive
14 | that the application is ready for interaction.
15 |
16 | Ember Interactivity allows us to generate latency metrics tailored to this definition;
17 | specifically, by identifying the critical components required to render a parent
18 | route or component, we can track load times and identify bottlenecks that are
19 | critical to the user experience. By focusing on perceived load times, we are
20 | able to reduce user bounce rates and churn through making the content appear to
21 | load faster. Some strategies for this involve adding placeholders for necessarily
22 | long content wait times, but often there is plenty of low-hanging fruit to make
23 | actual improvements if we have the proper instrumentation to locate these issues.
24 |
25 | Check out the [Demo](http://jhawk.co/interactivity-demo)!
26 |
27 | Want to see this addon used in a real application?
28 |
29 | [www.JordanHawker.com](https://www.jordanhawker.com/)
30 | is open-source, so you can see examples of how to use the features outlined below.
31 |
32 | Table of Contents
33 | ------------------------------------------------------------------------------
34 |
35 | * [Installation](#Installation)
36 | * [Usage](#usage)
37 | * [Routes](#routes)
38 | * [Components](#components)
39 | * [isInteractive](#isinteractive)
40 | * [Beacons](#beacons)
41 | * [Tracking](#tracking)
42 | * [Timeline Marking](#timeline-marking)
43 | * [Configuration](#configuration)
44 | * [Testing](#testing)
45 | * [Contributing](#contributing)
46 | * [License](#license)
47 |
48 | Installation
49 | ------------------------------------------------------------------------------
50 |
51 | ```
52 | ember install ember-interactivity
53 | ```
54 |
55 |
56 | Usage
57 | ------------------------------------------------------------------------------
58 |
59 | Ember Interactivity requires developers to instrument routes and
60 | critical components in order to report when they have completed rendering.
61 |
62 | ### Routes
63 |
64 | The `route-interactivity` mixin provides instrumentation for
65 | route latency. This can be added to [all routes](https://github.com/elwayman02/jordan-hawker/blob/master/app/ext/route.js):
66 |
67 | ```javascript
68 | // ext/route.js
69 | import Route from '@ember/routing/route';
70 | import RouteInteractivityMixin from 'ember-interactivity/mixins/route-interactivity';
71 |
72 | Route.reopen(RouteInteractivityMixin);
73 | ```
74 |
75 | ```javascript
76 | // app.js
77 | import './ext/route';
78 | ```
79 |
80 | Alternatively, add the mixin only to the routes you want instrumented:
81 |
82 | ```javascript
83 | // routes/foo.js
84 | import Route from '@ember/routing/route';
85 | import RouteInteractivityMixin from 'ember-interactivity/mixins/route-interactivity';
86 |
87 | export default Route.extend(RouteInteractivityMixin);
88 | ```
89 |
90 | By default, routes will naively report that it is interactive by
91 | scheduling an event in the `afterRender` queue. The instrumentation
92 | will take latency of the model hook into account, as well as any
93 | top-level render tasks This is an easy, but relatively inaccurate
94 | method of instrumentation. It is only recommended for routes that
95 | are either low priority for instrumentation or render only basic
96 | HTML elements with no components.
97 |
98 | For better instrumentation, read how to utilize the
99 | [isInteractive](#isInteractive) method.
100 |
101 | Note: The mixins in this addon rely on a number of lifecycle hooks,
102 | such as beforeModel & didTransition. If you have any issues sending events,
103 | please make sure you are calling `this._super(...arguments)` in your app when
104 | utilizing these hooks.
105 |
106 | ### Components
107 |
108 | The `component-interactivity` mixin provides instrumentation for
109 | component latency. This mixin should be added to all components that
110 | are required for a route to be interactive. For the most accurate data,
111 | instrument each top-level component's critical children as well. Non-critical
112 | components can also be instrumented to understand their own latency,
113 | even if they are not critical for a route or parent component to render.
114 |
115 | Like routes above, we can implement a [basic instrumentation strategy](https://github.com/elwayman02/jordan-hawker/blob/master/app/components/dj-bio.js#L10)
116 | via the `afterRender` queue. If a component renders only basic HTML elements
117 | and does not depend on any asynchronous behavior to render, this is an ideal approach:
118 |
119 | ```handlebars
120 | // templates/components/foo-bar.hbs
121 |
I am a basic template with no child components.
122 | ```
123 |
124 | ```javascript
125 | // components/foo-bar.js
126 | import Component from '@ember/component';
127 | import { run } from '@ember/runloop';
128 | import ComponentInteractivity from 'ember-interactivity/mixins/component-interactivity';
129 |
130 | export default Component.extend(ComponentInteractivity, {
131 | didInsertElement() {
132 | this._super(...arguments);
133 | run.scheduleOnce('afterRender', this, this.reportInteractive);
134 | }
135 | });
136 | ```
137 |
138 | If your component relies on asynchronous behavior (such as data loading),
139 | you can delay your `afterRender` scheduling until after that behavior completes.
140 |
141 | ```javascript
142 | // components/foo-bar.js
143 | import Component from '@ember/component';
144 | import { run } from '@ember/runloop';
145 | import ComponentInteractivity from 'ember-interactivity/mixins/component-interactivity';
146 |
147 | export default Component.extend(ComponentInteractivity, {
148 | init() {
149 | this._super(...arguments);
150 |
151 | this.loadData().then(() => {
152 | run.scheduleOnce('afterRender', this, this.reportInteractive);
153 | });
154 | }
155 | });
156 | ```
157 |
158 | For components that rely on their child components to be interactive,
159 | read how to utilize the [isInteractive](#isInteractive) method.
160 |
161 | ### isInteractive
162 |
163 | In order to instrument latency more accurately, we define the list of
164 | components we expect to report as interactive in order to complete
165 | the critical rendering path of the route/component (known as the "subscriber").
166 | This is handled by implementing an [`isInteractive` method](https://github.com/elwayman02/jordan-hawker/blob/master/app/routes/music.js#L4-L6)
167 | in each subscriber. This method is passed a function that will tell you if a reporter is interactive.
168 |
169 | ```javascript
170 | // routes/foo.js or components/foo-bar.js
171 | isInteractive(didReportInteractive) {
172 | return didReportInteractive('first-component') && didReportInteractive('second-component');
173 | }
174 | ```
175 |
176 | Pass `didReportInteractive` the name of a component the subscriber renders
177 | that is considered critical for interactivity. Once `isInteractive`
178 | returns true, the relevant tracking events will be fired.
179 |
180 | If you expect the subscriber to render multiple instances of the same component
181 | (e.g. an `#each` loop), you can [pass the expected number](https://github.com/elwayman02/jordan-hawker/blob/master/app/components/github-projects.js#L28-L30)
182 | to `didReportInteractive`:
183 |
184 | ```javascript
185 | // routes/foo.js or components/foo-bar.js
186 | isInteractive(didReportInteractive) {
187 | let count = this.get('someData.length');
188 | return didReportInteractive('first-component', { count }) && didReportInteractive('second-component');
189 | }
190 | ```
191 |
192 | If there are multiple interactivity states to consider, simply add those
193 | conditions to `isInteractive`:
194 |
195 | ```handlebars
196 | // templates/foo.hbs or templates/components/foo-bar.hbs
197 | {{if someState}}
198 | {{first-component}}
199 | {{else}}
200 | {{second-component}}
201 | {{/if}}
202 | ```
203 |
204 | ```javascript
205 | // routes/foo.js or components/foo-bar.js
206 | isInteractive(didReportInteractive) {
207 | if (this.get('someState')) {
208 | return didReportInteractive('first-component');
209 | }
210 | return didReportInteractive('second-component');
211 | }
212 | ```
213 |
214 | ### Beacons
215 |
216 | Often a template has multiple rendering states (e.g. a loading state),
217 | which may or may not render child components. If such a situation occurs,
218 | neither basic or complex instrumentation is a perfect fit. To address this,
219 | Ember Interactivity provides an `interactivity-beacon` component. These
220 | beacons are simple components that you can append to the end of a template
221 | block in order to time the rendering of that block.
222 |
223 | Provide the beacon with a `beaconId` to give it a unique identifier:
224 |
225 | ```handlebars
226 | // routes/foo.js or components/foo-bar.js
227 | {{#if isLoading}}
228 |
Loading...
229 | {{interactivity-beacon beaconId='foo-loading'}}
230 | {{else}}
231 | {{first-component}}
232 | {{second-component}}
233 | {{/if}}
234 | ```
235 |
236 | Each `beaconId` is prepended with 'beacon:' for use in `didReportInteractive`:
237 |
238 | ```javascript
239 | // routes/foo.js or components/foo-bar.js
240 | isInteractive(didReportInteractive) {
241 | if (this.get('isLoading')) {
242 | return didReportInteractive('beacon:foo-loading');
243 | }
244 | return didReportInteractive('first-component') && didReportInteractive('second-component');
245 | }
246 | ```
247 |
248 | ### Tracking
249 |
250 | Ember Interactivity sends its events to the `interactivity-tracking` service.
251 | Use this interface to implement your own integration points for sending data
252 | to your favorite analytics service. For example, if you want to use [`ember-metrics`](https://github.com/poteto/ember-metrics)
253 | to send interactivity events to Mixpanel:
254 |
255 | ```javascript
256 | // app/services/interactivity-tracking.js
257 | import { inject as service } from '@ember/service';
258 | import InteractivityTrackingService from 'ember-interactivity/services/interactivity-tracking';
259 |
260 | export default InteractivityTrackingService.extend({
261 | metrics: service(),
262 |
263 | trackComponent(data) {
264 | this.get('metrics').trackEvent('mixpanel', data);
265 | }
266 |
267 | trackRoute(data) {
268 | this.get('metrics').trackEvent('mixpanel', data);
269 | }
270 | });
271 | ```
272 |
273 | The interface is simple; it just passes through a data object for
274 | various events, and you can handle them however you like. All data will
275 | include an `event` name as detailed below; you can map these strings to
276 | whatever names you prefer for sending to your analytics service.
277 |
278 | #### trackRoute
279 |
280 | This method is called whenever a route interactivity event is triggered.
281 | There are three possible events: `routeInitializing`, `routeActivating`, & `routeInitialized`
282 |
283 | These events are useful for segmenting your route latency numbers to know
284 | if bottlenecks are caused by your APIs, the actual content rendering, or
285 | some upstream app dependency (such as the CDN). Each `trackRoute` event
286 | passes the following base data:
287 |
288 | * event - The name of the event (e.g. `routeInitializing`)
289 | * clientTime - The time the event occurred, formatted as a Float
290 | * destination - The destination route for the transition
291 | * routeName - The name of the route this event belongs to
292 | * lostVisibility - Whether or not the app lost visibility
293 |
294 | When `routeName` and `destination` are the same, you are on a leaf route
295 | (as opposed to a parent route whose hooks trigger as part of the rendering process).
296 | By default only leaf routes report interactivity, so while all routes will fire
297 | `routeInitializing` & `routeActivating` events, only leaf routes
298 | (or routes where `isInteractive` is defined) send `routeInitialized`.
299 |
300 | ###### Visibility Tracking
301 |
302 | Ember Interactivity uses [`ember-is-visible`](https://github.com/elwayman02/ember-is-visible)
303 | to track if the document loses visibility while the route is loading. This is
304 | useful because the browser may de-optimize loading some part of your application
305 | when a user switches tabs to another site. Using this data, we can identify events
306 | where latency numbers may be increased due to visibility loss, as well as
307 | track user behavior to know if they are frequently moving away from the site
308 | while waiting for it to load.
309 |
310 | ##### routeInitializing
311 |
312 | This event is called from the `beforeModel` hook of your route and
313 | indicates the beginning of each route's loading phases.
314 |
315 | ##### routeActivating
316 |
317 | This event is called when the `activate` hook is triggered, after the model hooks complete.
318 | This is the point at which the route will begin scheduling its rendering tasks.
319 |
320 | ##### routeInteractive
321 |
322 | This event is called when the route reports itself as interactive, per the definitions
323 | outlined above. In addition to the base data, two additional properties are added to this event:
324 |
325 | * isAppLaunch - Boolean indicating if the app is launching for first time
326 | or if this is a transition from another route.
327 | * timeElapsed - This indicates the time (in milliseconds) that the route
328 | took to become interactive since the initial browser fetch. Only included
329 | if `isAppLaunch` is true.
330 |
331 | `timeElapsed` is usually your primary data point for tracking the load times of your routes.
332 |
333 | #### trackComponent
334 |
335 | This method is called whenever a component interactivity event is triggered.
336 | There are two possible events: `componentInitializing` & `componentInteractive`
337 |
338 | Event data contains the following properties:
339 |
340 | * event - The name of the event (e.g. `componentInteractive`)
341 | * clientTime - The time the event occurred, formatted as a Float
342 | * component - The name of the component
343 | * componentId - A unique id for the component (to differentiate instances of the same component)
344 |
345 | The `componentInteractive` event adds an additional property:
346 |
347 | * timeElapsed - This indicates the time (in milliseconds) that the component
348 | took to become interactive since it began initializing.
349 | (Essentially subtracting the clientTimes for the two events)
350 |
351 | ##### isComponentInstrumentationDisabled
352 |
353 | This method allows you to control whether components are instrumented in the application.
354 | By default, it reads the configuration property [`tracking.disableComponents`](#Configuration),
355 | but you can override the method to add custom logic for when to disable instrumentation.
356 |
357 | #### trackError (_Experimental_)
358 |
359 | This method is called whenever an error occurs in Ember Interactivity.
360 | Currently, no data is sent along with an error; please file issues if you
361 | have requests for data to include! `trackError` is only hooked up for routes
362 | at the moment, such as when a user has transitioned away from the route before completion.
363 |
364 | ### Timeline Marking
365 |
366 | Ember Interactivity automatically marks each route/component using the
367 | [Performance Timeline](https://developer.mozilla.org/en-US/docs/Web/API/Performance_Timeline/Using_Performance_Timeline)
368 | standard. DevTools such as the [Chrome Timeline](https://developers.google.com/web/tools/chrome-devtools/evaluate-performance/reference)
369 | can display the timings for easy visualization of the critical rendering waterfall.
370 | This can help developers identify bottlenecks for optimizing time to interactivity.
371 |
372 | 
373 |
374 | Note: It's important to realize that in some cases, components you may not have
375 | considered to be critical are creating rendering bottlenecks in your application.
376 | Look for suspicious gaps in the rendering visualization to identify these situations.
377 |
378 | ### Configuration
379 |
380 | Developers can toggle individual features of Ember Interactivity by
381 | adding an `interactivity` object to their application's environment config.
382 | This can be useful if you only want features run in certain environments,
383 | or if you want to sample a percentage of your users to stay within data storage limits.
384 |
385 | Three features can be configured:
386 |
387 | * `instrumentation` - Toggle instrumentation altogether (Note: Does not support leaf/parent configs below)
388 | * `timelineMarking` - Toggle marking the performance timeline
389 | * `tracking` - Toggle sending tracking events
390 |
391 | Each feature can be configured for four subsets of the addon:
392 |
393 | * `disableComponents` - Set true to disable for all components
394 | * `disableLeafComponents` - Set true to disable for child components
395 | (those that do not implement `isInteractive`). This is useful if you
396 | only want a feature enabled for subscribers (parent routes/components).
397 | * `disableRoutes` - Set true to disable for all routes
398 | * `disableParentRoutes` - Set true to disable for all non-leaf routes
399 | (those that are not the target of a transition). This is useful if you
400 | aren't trying to identify bottlenecks in your route chain and just want
401 | to collect latency numbers for each transition.
402 |
403 | ```javascript
404 | // config/environment.js
405 | module.exports = function (environment) {
406 | let ENV = {
407 | interactivity: {
408 | tracking: {
409 | disableLeafComponents: true
410 | },
411 | timelineMarking: {
412 | disableRoutes: true
413 | }
414 | }
415 | };
416 | return ENV;
417 | };
418 | ```
419 |
420 | #### Overrides
421 |
422 | TODO: Per-instance Overrides
423 |
424 | ### Testing
425 |
426 | Ember Interactivity provides a number of test helpers to support testing your application's latency instrumentation.
427 |
428 | #### Mock Services
429 |
430 | Mock service instances are provided for your use. It is recommended to
431 | register these mock services in each of the tests of your application.
432 |
433 | ```javascript
434 | import MockInteractivityService from 'ember-interactivity/test-support/mock-interactivity-service';
435 | import MockInteractivityTrackingService from 'ember-interactivity/test-support/mock-interactivity-tracking-service';
436 |
437 | module('foo', 'Integration | Component | foo', function (hooks) {
438 | setupRenderingTest(hooks);
439 |
440 | hooks.beforeEach(function () {
441 | this.owner.register('service:interactivity', MockInteractivityService);
442 | this.owner.register('service:interactivity-tracking', MockInteractivityTrackingService);
443 | });
444 | });
445 | ```
446 |
447 | To avoid writing this for every test in your application, you can write
448 | a wrapper around `module` that handles registering any mock services for your tests.
449 |
450 | #### Interactivity Assertions
451 |
452 | The `assert-interactivity` helper provides methods to test that your routes/components
453 | are correctly reporting latency events when rendering. As your tests exercise
454 | these modules, these assertions will confirm the interactivity events get sent.
455 | This helper relies on the `MockInteractivityService` being registered.
456 |
457 | First, make the assertion available to your tests:
458 |
459 | ```javascript
460 | // tests/test-helper.js
461 | import 'ember-interactivity/test-support/assert-interactivity';
462 | ```
463 |
464 | Then, use the `trackInteractivity` assertion in your tests for routes and component subscribers:
465 |
466 | ```javascript
467 | // tests/acceptance/foo.js
468 | import { module, test } from 'qunit';
469 | import { click, fillIn, visit } from '@ember/test-helpers';
470 | import { setupApplicationTest } from 'ember-qunit';
471 |
472 | module('Acceptance | foo', function (hooks) {
473 | setupApplicationTest(hooks);
474 |
475 | test('should report interactive', async function (assert) {
476 | await visit('/foo');
477 | assert.trackInteractivity('foo');
478 | });
479 | });
480 | ```
481 |
482 | Let's say you want to simulate some async behavior and make sure interactivity
483 | conditions aren't being fulfilled prematurely. The `trackNonInteractivity`
484 | assertion can be used to test this scenario:
485 |
486 | ```javascript
487 | // tests/acceptance/foo.js
488 | import { module, test } from 'qunit';
489 | import { click, fillIn, visit } from '@ember/test-helpers';
490 | import { setupApplicationTest } from 'ember-qunit';
491 |
492 | module('Acceptance | foo', function (hooks) {
493 | setupApplicationTest(hooks);
494 |
495 | hooks.beforeEach(function () {
496 | this.resolveAsyncBehavior = () => {
497 | // Do stuff to resolve interactivity conditions
498 | };
499 | });
500 |
501 | test('should report interactive', async function (assert) {
502 | await visit('/foo');
503 | assert.trackNonInteractivity('foo');
504 | this.resolveAsyncBehavior();
505 | assert.trackInteractivity('foo');
506 | });
507 | });
508 | ```
509 |
510 | Contributing
511 | ------------------------------------------------------------------------------
512 |
513 | We adhere to the [Ember Community Guidelines](https://emberjs.com/guidelines/) for our Code of Conduct.
514 |
515 | [](https://www.netlify.com)
516 |
517 | ### Installation
518 |
519 | * `git clone https://www.github.com/elwayman02/ember-interactivity.git`
520 | * `cd ember-interactivity`
521 | * `yarn install`
522 |
523 | ### Linting
524 |
525 | * `yarn lint:js`
526 | * `yarn lint:js --fix`
527 |
528 | ### Running tests
529 |
530 | * `ember test` – Runs the test suite on the current Ember version
531 | * `ember test --server` – Runs the test suite in "watch mode"
532 | * `ember try:each` – Runs the test suite against multiple Ember versions
533 |
534 | ### Running the dummy application
535 |
536 | * `ember serve`
537 | * Visit the dummy application at [http://localhost:4200](http://localhost:4200).
538 |
539 | For more information on using ember-cli, visit [https://ember-cli.com/](https://ember-cli.com/).
540 |
541 | License
542 | ------------------------------------------------------------------------------
543 |
544 | This project is licensed under the [MIT License](LICENSE.md).
545 |
--------------------------------------------------------------------------------