├── app ├── .gitkeep ├── services │ ├── interactivity.js │ └── interactivity-tracking.js └── components │ └── interactivity-beacon.js ├── addon ├── .gitkeep ├── templates │ └── components │ │ └── interactivity-beacon.hbs ├── utils │ ├── date.js │ ├── config.js │ ├── timeline-marking.js │ ├── interactivity.js │ └── interactivity-subscriber.js ├── components │ └── interactivity-beacon.js ├── services │ ├── interactivity-tracking.js │ └── interactivity.js └── mixins │ ├── component-interactivity.js │ └── route-interactivity.js ├── vendor └── .gitkeep ├── tests ├── unit │ ├── .gitkeep │ ├── services │ │ ├── interactivity-tracking-test.js │ │ └── interactivity-test.js │ └── mixins │ │ ├── component-interactivity-test.js │ │ └── route-interactivity-test.js ├── integration │ ├── .gitkeep │ └── components │ │ └── interactivity-beacon-test.js ├── dummy │ ├── app │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── models │ │ │ └── .gitkeep │ │ ├── routes │ │ │ ├── .gitkeep │ │ │ ├── index.js │ │ │ └── docs.js │ │ ├── components │ │ │ ├── .gitkeep │ │ │ ├── delayed-interactivity.js │ │ │ ├── default-interactivity.js │ │ │ └── parent-interactivity.js │ │ ├── controllers │ │ │ └── .gitkeep │ │ ├── templates │ │ │ ├── components │ │ │ │ ├── .gitkeep │ │ │ │ ├── delayed-interactivity.hbs │ │ │ │ ├── default-interactivity.hbs │ │ │ │ └── parent-interactivity.hbs │ │ │ ├── application.hbs │ │ │ └── index.hbs │ │ ├── resolver.js │ │ ├── ext │ │ │ └── route.js │ │ ├── styles │ │ │ └── app.css │ │ ├── services │ │ │ └── interactivity-tracking.js │ │ ├── app.js │ │ ├── index.html │ │ └── router.js │ ├── public │ │ ├── _redirects │ │ ├── robots.txt │ │ └── assets │ │ │ └── demo-waterfall.png │ └── config │ │ ├── targets.js │ │ └── environment.js ├── helpers │ ├── destroy-app.js │ ├── start-app.js │ └── module-for-acceptance.js ├── test-helper.js └── index.html ├── .watchmanconfig ├── index.js ├── docs ├── hero-logo.png └── waterfall.png ├── config ├── environment.js ├── release.js ├── addon-docs.js └── ember-try.js ├── .eslintignore ├── .ember-cli ├── .npmignore ├── .editorconfig ├── .gitignore ├── ember-cli-build.js ├── addon-test-support ├── mock-interactivity-tracking-service.js ├── mock-interactivity-service.js └── assert-interactivity.js ├── testem.js ├── LICENSE.md ├── .eslintrc.js ├── .travis.yml ├── package.json └── README.md /app/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /addon/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /addon/templates/components/interactivity-beacon.hbs: -------------------------------------------------------------------------------- 1 | {{yield}} -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | name: 'ember-interactivity' 5 | }; 6 | -------------------------------------------------------------------------------- /app/services/interactivity.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-interactivity/services/interactivity'; 2 | -------------------------------------------------------------------------------- /docs/hero-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elwayman02/ember-interactivity/HEAD/docs/hero-logo.png -------------------------------------------------------------------------------- /docs/waterfall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elwayman02/ember-interactivity/HEAD/docs/waterfall.png -------------------------------------------------------------------------------- /tests/dummy/app/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember-resolver'; 2 | 3 | export default Resolver; 4 | -------------------------------------------------------------------------------- /app/components/interactivity-beacon.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-interactivity/components/interactivity-beacon'; -------------------------------------------------------------------------------- /app/services/interactivity-tracking.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-interactivity/services/interactivity-tracking'; 2 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(/* environment, appConfig */) { 4 | return { }; 5 | }; 6 | -------------------------------------------------------------------------------- /config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | module.exports = { 4 | manifest: ['package.json'], 5 | publish: true 6 | }; 7 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | {{docs-navbar logo='ember' githubUrl='http://jhawk.co/ember-interactivity'}} 2 | 3 | {{outlet}} -------------------------------------------------------------------------------- /tests/dummy/public/assets/demo-waterfall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elwayman02/ember-interactivity/HEAD/tests/dummy/public/assets/demo-waterfall.png -------------------------------------------------------------------------------- /tests/helpers/destroy-app.js: -------------------------------------------------------------------------------- 1 | import { run } from '@ember/runloop'; 2 | 3 | export default function destroyApp(application) { 4 | run(application, 'destroy'); 5 | } 6 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/components/delayed-interactivity.hbs: -------------------------------------------------------------------------------- 1 |

This component implements 500ms delayed interactivity, in order to simulate an asynchronous callback such as loading data.

-------------------------------------------------------------------------------- /tests/dummy/app/ext/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import RouteInteractivityMixin from 'ember-interactivity/mixins/route-interactivity'; 3 | 4 | Route.reopen(RouteInteractivityMixin); 5 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({ 4 | isInteractive(didReportInteractive) { 5 | return didReportInteractive('parent-interactivity'); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /addon/utils/date.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats the current time as a float 3 | * 4 | * @method getTimeAsFloat 5 | * 6 | * @return {number} Current time 7 | */ 8 | export function getTimeAsFloat() { 9 | return new Date().getTime() / 1000; 10 | } 11 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/docs.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({ 4 | redirect() { 5 | window.location.replace('https://github.com/elwayman02/ember-interactivity/blob/master/README.md'); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | 12 | # misc 13 | /coverage/ 14 | 15 | # ember-try 16 | /.node_modules.ember-try/ 17 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/components/default-interactivity.hbs: -------------------------------------------------------------------------------- 1 |

This component implements default interactivity by 2 | scheduling an "afterRender" callback in "didInsertElement".

-------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- 1 | .description p { 2 | padding-bottom: 5px; 3 | } 4 | 5 | .version { 6 | padding-bottom: 0; 7 | } 8 | 9 | .badges { 10 | display: flex; 11 | flex-direction: row; 12 | justify-content: space-between; 13 | max-width: 550px; 14 | } 15 | 16 | .screenshot { 17 | width: 100%; 18 | } 19 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import Application from '../app'; 2 | import config from '../config/environment'; 3 | import { setApplication } from '@ember/test-helpers'; 4 | import { start } from 'ember-qunit'; 5 | 6 | import 'ember-interactivity/test-support/assert-interactivity'; 7 | 8 | setApplication(Application.create(config.APP)); 9 | 10 | start(); 11 | -------------------------------------------------------------------------------- /addon/utils/config.js: -------------------------------------------------------------------------------- 1 | import { getOwner } from '@ember/application'; 2 | 3 | export default function getInteractivityConfig(scope) { 4 | let owner = getOwner(scope); 5 | if (owner) { 6 | let env = owner.resolveRegistration('config:environment'); 7 | if (env) { 8 | return env.interactivity || {}; 9 | } 10 | } 11 | 12 | return {}; 13 | } 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /bower_components 2 | /config/ember-try.js 3 | /dist 4 | /tests 5 | /tmp 6 | **/.gitkeep 7 | .bowerrc 8 | .editorconfig 9 | .ember-cli 10 | .eslintrc.js 11 | .gitignore 12 | .watchmanconfig 13 | .travis.yml 14 | bower.json 15 | ember-cli-build.js 16 | testem.js 17 | yarn.lock 18 | 19 | # ember-try 20 | .node_modules.ember-try/ 21 | bower.json.ember-try 22 | package.json.ember-try 23 | -------------------------------------------------------------------------------- /config/addon-docs.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | const AddonDocsConfig = require('ember-cli-addon-docs/lib/config'); // eslint-disable-line node/no-unpublished-require 5 | 6 | module.exports = class extends AddonDocsConfig { 7 | // See https://ember-learn.github.io/ember-cli-addon-docs/latest/docs/deploying 8 | // for details on configuration you can override here. 9 | }; 10 | -------------------------------------------------------------------------------- /tests/dummy/config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 1 Chrome versions', 5 | 'last 1 Firefox versions', 6 | 'last 1 Safari versions' 7 | ]; 8 | 9 | const isCI = !!process.env.CI; 10 | const isProduction = process.env.EMBER_ENV === 'production'; 11 | 12 | if (isCI || isProduction) { 13 | browsers.push('ie 11'); 14 | } 15 | 16 | module.exports = { 17 | browsers 18 | }; 19 | -------------------------------------------------------------------------------- /tests/dummy/app/components/delayed-interactivity.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { run } from '@ember/runloop'; 3 | import ComponentInteractivity from 'ember-interactivity/mixins/component-interactivity'; 4 | 5 | export default Component.extend(ComponentInteractivity, { 6 | didInsertElement() { 7 | this._super(...arguments); 8 | run.later(this, this.reportInteractive, 500); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /tests/unit/services/interactivity-tracking-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'ember-qunit'; 3 | 4 | module('Unit | Service | interactivity', function (hooks) { 5 | setupTest(hooks); 6 | 7 | // Replace this with your real tests. 8 | test('it exists', function (assert) { 9 | let service = this.owner.lookup('service:interactivity-tracking'); 10 | assert.ok(service); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /tests/dummy/app/components/default-interactivity.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { run } from '@ember/runloop'; 3 | import ComponentInteractivity from 'ember-interactivity/mixins/component-interactivity'; 4 | 5 | export default Component.extend(ComponentInteractivity, { 6 | didInsertElement() { 7 | this._super(...arguments); 8 | run.scheduleOnce('afterRender', this, this.reportInteractive); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.hbs] 17 | insert_final_newline = false 18 | 19 | [*.{diff,md}] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist/ 5 | /tmp/ 6 | 7 | # dependencies 8 | /bower_components/ 9 | /node_modules/ 10 | 11 | # misc 12 | /.sass-cache 13 | /connect.lock 14 | /coverage/ 15 | /libpeerconnection.log 16 | /npm-debug.log* 17 | /testem.log 18 | /yarn-error.log 19 | 20 | # ember-try 21 | /.node_modules.ember-try/ 22 | /bower.json.ember-try 23 | /package.json.ember-try 24 | -------------------------------------------------------------------------------- /tests/dummy/app/components/parent-interactivity.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import ComponentInteractivity from 'ember-interactivity/mixins/component-interactivity'; 3 | 4 | export default Component.extend(ComponentInteractivity, { 5 | isInteractive(didReportInteractive) { 6 | return didReportInteractive('default-interactivity') && 7 | didReportInteractive('delayed-interactivity') && didReportInteractive('beacon:myBeacon'); 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /tests/dummy/app/services/interactivity-tracking.js: -------------------------------------------------------------------------------- 1 | import { inject as service } from '@ember/service'; 2 | import InteractivityTrackingService from 'ember-interactivity/services/interactivity-tracking'; 3 | 4 | export default InteractivityTrackingService.extend({ 5 | metrics: service(), 6 | 7 | trackComponent(data) { 8 | this.get('metrics').trackEvent(data); 9 | }, 10 | 11 | trackRoute(data) { 12 | this.get('metrics').trackEvent(data); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import Resolver from './resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from './config/environment'; 5 | 6 | import './ext/route'; 7 | 8 | const App = Application.extend({ 9 | modulePrefix: config.modulePrefix, 10 | podModulePrefix: config.podModulePrefix, 11 | Resolver 12 | }); 13 | 14 | loadInitializers(App, config.modulePrefix); 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/components/parent-interactivity.hbs: -------------------------------------------------------------------------------- 1 |

This component implements interactivity via its child components:

2 | 3 | {{default-interactivity}} 4 | 5 | {{delayed-interactivity}} 6 | 7 |

An interactivity beacon is rendered after this text block.

8 | 9 | {{interactivity-beacon beaconId='myBeacon'}} -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 4 | 5 | module.exports = function(defaults) { 6 | let app = new EmberAddon(defaults, { 7 | // Add options here 8 | }); 9 | 10 | /* 11 | This build file specifies the options for the dummy test app of this 12 | addon, located in `/tests/dummy` 13 | This build file does *not* influence how the addon or the app using it 14 | behave. You most likely want to be modifying `./index.js` or app's build file 15 | */ 16 | 17 | return app.toTree(); 18 | }; 19 | -------------------------------------------------------------------------------- /addon/utils/timeline-marking.js: -------------------------------------------------------------------------------- 1 | export const INITIALIZING_LABEL = 'Initializing'; 2 | export const INTERACTIVE_LABEL = 'Interactive'; 3 | 4 | const MEASURE_LABEL = 'Latency'; 5 | 6 | export function markTimeline(type, getTimelineLabel) { 7 | if (performance && performance.mark) { // TODO: Optional heimdall integration? 8 | performance.mark(getTimelineLabel(type)); 9 | 10 | if (performance.measure && type === INTERACTIVE_LABEL) { 11 | performance.measure(getTimelineLabel(MEASURE_LABEL), getTimelineLabel(INITIALIZING_LABEL), getTimelineLabel(INTERACTIVE_LABEL)); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/helpers/start-app.js: -------------------------------------------------------------------------------- 1 | import Application from '../../app'; 2 | import config from '../../config/environment'; 3 | import { merge } from '@ember/polyfills'; 4 | import { run } from '@ember/runloop'; 5 | 6 | export default function startApp(attrs) { 7 | let attributes = merge({}, config.APP); 8 | attributes.autoboot = true; 9 | attributes = merge(attributes, attrs); // use defaults, but you can override; 10 | 11 | return run(() => { 12 | let application = Application.create(attributes); 13 | application.setupForTesting(); 14 | application.injectTestHelpers(); 15 | return application; 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /addon-test-support/mock-interactivity-tracking-service.js: -------------------------------------------------------------------------------- 1 | import InteractivityTrackingService from 'ember-interactivity/services/interactivity-tracking'; 2 | 3 | export default InteractivityTrackingService.extend({ 4 | _trackedComponentCalls: null, 5 | _trackedRouteCalls: null, 6 | 7 | init() { 8 | this._super(...arguments); 9 | this.resetInvocations(); 10 | }, 11 | 12 | trackComponent(data) { 13 | this._trackedComponentCalls.push(data); 14 | }, 15 | 16 | trackRoute(data) { 17 | this._trackedRouteCalls.push(data); 18 | }, 19 | 20 | resetInvocations() { 21 | this._trackedComponentCalls = []; 22 | this._trackedRouteCalls = []; 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | test_page: 'tests/index.html?hidepassed', 3 | disable_watching: true, 4 | launch_in_ci: [ 5 | 'Chrome' 6 | ], 7 | launch_in_dev: [ 8 | 'Chrome' 9 | ], 10 | browser_args: { 11 | Chrome: { 12 | ci: [ 13 | // --no-sandbox is needed when running Chrome inside a container 14 | process.env.CI ? '--no-sandbox' : null, 15 | '--headless', 16 | '--disable-gpu', 17 | '--disable-dev-shm-usage', 18 | '--disable-software-rasterizer', 19 | '--mute-audio', 20 | '--remote-debugging-port=0', 21 | '--window-size=1440,900' 22 | ].filter(Boolean) 23 | } 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /tests/helpers/module-for-acceptance.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | import { resolve } from 'rsvp'; 3 | import startApp from '../helpers/start-app'; 4 | import destroyApp from '../helpers/destroy-app'; 5 | 6 | export default function(name, options = {}) { 7 | module(name, { 8 | beforeEach() { 9 | this.application = startApp(); 10 | 11 | if (options.beforeEach) { 12 | return options.beforeEach.apply(this, arguments); 13 | } 14 | }, 15 | 16 | afterEach() { 17 | let afterEach = options.afterEach && options.afterEach.apply(this, arguments); 18 | return resolve(afterEach).then(() => destroyApp(this.application)); 19 | } 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /addon/components/interactivity-beacon.js: -------------------------------------------------------------------------------- 1 | import Component from '@ember/component'; 2 | import { run } from '@ember/runloop'; 3 | import { computed } from '@ember/object'; 4 | import ComponentInteractivity from 'ember-interactivity/mixins/component-interactivity'; 5 | import layout from '../templates/components/interactivity-beacon'; 6 | 7 | export default Component.extend(ComponentInteractivity, { 8 | layout, 9 | beaconId: '', 10 | _latencyReportingName: computed('beaconId', function () { 11 | return `beacon:${this.get('beaconId')}`; 12 | }), 13 | didInsertElement() { 14 | this._super(...arguments); 15 | run.scheduleOnce('afterRender', this, this.reportInteractive); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /addon-test-support/mock-interactivity-service.js: -------------------------------------------------------------------------------- 1 | import InteractivityService from 'ember-interactivity/services/interactivity'; 2 | 3 | export default InteractivityService.extend({ 4 | _reportedSubscribers: null, 5 | 6 | init() { 7 | this._super(...arguments); 8 | this.resetInvocations(); 9 | }, 10 | 11 | subscribeComponent(options) { 12 | return this.addSubscriber(options); 13 | }, 14 | 15 | subscribeRoute(options) { 16 | return this.addSubscriber(options); 17 | }, 18 | 19 | addSubscriber(options) { 20 | return this._super(...arguments).then(() => { 21 | this._reportedSubscribers.push(options); 22 | }); 23 | }, 24 | 25 | resetInvocations() { 26 | this._reportedSubscribers = []; 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ember Interactivity Demo 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} 16 | 17 | 18 | {{content-for "body"}} 19 | 20 | 21 | 22 | 23 | {{content-for "body-footer"}} 24 | 25 | 26 | -------------------------------------------------------------------------------- /addon/services/interactivity-tracking.js: -------------------------------------------------------------------------------- 1 | import Service from '@ember/service'; 2 | import getConfig from 'ember-interactivity/utils/config'; 3 | 4 | export default Service.extend({ 5 | trackComponent(/* data */) { 6 | if (this.isComponentInstrumentationDisabled()) { 7 | return; 8 | } 9 | }, 10 | 11 | trackRoute(/* data */) { 12 | if (this.isRouteInstrumentationDisabled()) { 13 | return; 14 | } 15 | }, 16 | 17 | trackError() { 18 | 19 | }, 20 | 21 | isComponentInstrumentationDisabled() { 22 | let options = getConfig(this); 23 | 24 | return options.instrumentation && options.instrumentation.disableComponents; 25 | }, 26 | 27 | isRouteInstrumentationDisabled() { 28 | let options = getConfig(this); 29 | 30 | return options.instrumentation && options.instrumentation.disableRoutes; 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from './config/environment'; 3 | import { inject as service } from '@ember/service'; 4 | import { scheduleOnce } from '@ember/runloop'; 5 | 6 | const Router = EmberRouter.extend({ 7 | location: config.locationType, 8 | rootURL: config.rootURL, 9 | metrics: service(), 10 | 11 | didTransition() { 12 | this._super(...arguments); 13 | this._trackPage(); 14 | }, 15 | 16 | _trackPage() { 17 | scheduleOnce('afterRender', this, () => { 18 | const page = this.get('url'); 19 | const title = this.getWithDefault('currentRouteName', 'unknown'); 20 | 21 | this.get('metrics').trackPage({ page, title, event: 'pageViewed' }); 22 | }); 23 | } 24 | }); 25 | 26 | Router.map(function() { 27 | this.route('docs'); 28 | }); 29 | 30 | export default Router; 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Jordan Hawker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{content-for "body-footer"}} 31 | {{content-for "test-body-footer"}} 32 | 33 | 34 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | ecmaVersion: 2017, 5 | sourceType: 'module' 6 | }, 7 | plugins: [ 8 | 'ember' 9 | ], 10 | extends: [ 11 | 'eslint:recommended', 12 | 'plugin:ember/recommended' 13 | ], 14 | env: { 15 | browser: true 16 | }, 17 | rules: { 18 | }, 19 | overrides: [ 20 | // node files 21 | { 22 | files: [ 23 | 'ember-cli-build.js', 24 | 'index.js', 25 | 'testem.js', 26 | 'blueprints/*/index.js', 27 | 'config/**/*.js', 28 | 'tests/dummy/config/**/*.js' 29 | ], 30 | excludedFiles: [ 31 | 'addon/**', 32 | 'addon-test-support/**', 33 | 'app/**', 34 | 'tests/dummy/app/**' 35 | ], 36 | parserOptions: { 37 | sourceType: 'script', 38 | ecmaVersion: 2015 39 | }, 40 | env: { 41 | browser: false, 42 | node: true 43 | }, 44 | plugins: ['node'], 45 | rules: Object.assign({}, require('eslint-plugin-node').configs.recommended.rules, { 46 | // add your custom rules and overrides for node files here 47 | }) 48 | } 49 | ] 50 | }; 51 | -------------------------------------------------------------------------------- /addon/utils/interactivity.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions which can be used to compose isInteractive definitions 3 | */ 4 | 5 | /** 6 | * Builds the component name based on its `toString` property 7 | * Ex: 8 | * Returns 'foo-bar/baz-bat' 9 | * 10 | * WARNING: These ids are not unique! Use `getLatencySubscriptionId` for 11 | * a unique identifier. Use these names for linking top-down relationships 12 | * where the unique id is not known by the parent. 13 | * 14 | * @method getLatencyReportingName 15 | * 16 | * @param {Ember.Component} component - An Ember Component 17 | * @return {string} The name of the component 18 | */ 19 | 20 | export function getLatencyReportingName(component) { 21 | return component.toString().split(':')[1]; 22 | } 23 | 24 | /** 25 | * Builds the unique component id based on its `toString` property 26 | * Ex: 27 | * 28 | * @method getLatencySubscriptionId 29 | * 30 | * @param {Ember.Component} component - An Ember Component 31 | * @return {string} The unique id of the component 32 | */ 33 | export function getLatencySubscriptionId(component) { 34 | return component.toString(); 35 | } 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | node_js: 4 | # we recommend testing addons with the same minimum supported node version as Ember CLI 5 | # so that your addon works for all apps 6 | - "6" 7 | 8 | sudo: false 9 | dist: trusty 10 | 11 | addons: 12 | chrome: stable 13 | 14 | cache: 15 | yarn: true 16 | 17 | env: 18 | global: 19 | # See https://git.io/vdao3 for details. 20 | - JOBS=1 21 | matrix: 22 | # we recommend new addons test the current and previous LTS 23 | # as well as latest stable release (bonus points to beta/canary) 24 | - EMBER_TRY_SCENARIO=ember-lts-2.12 25 | - EMBER_TRY_SCENARIO=ember-lts-2.16 26 | - EMBER_TRY_SCENARIO=ember-lts-2.18 27 | - EMBER_TRY_SCENARIO=ember-release 28 | - EMBER_TRY_SCENARIO=ember-beta 29 | - EMBER_TRY_SCENARIO=ember-canary 30 | - EMBER_TRY_SCENARIO=ember-default 31 | 32 | matrix: 33 | fast_finish: true 34 | allow_failures: 35 | - env: EMBER_TRY_SCENARIO=ember-canary 36 | 37 | before_install: 38 | - curl -o- -L https://yarnpkg.com/install.sh | bash 39 | - export PATH=$HOME/.yarn/bin:$PATH 40 | - yarn global add greenkeeper-lockfile@1 41 | 42 | install: 43 | - yarn install --non-interactive 44 | 45 | before_script: 46 | - greenkeeper-lockfile-update 47 | 48 | script: 49 | - yarn lint:js 50 | # Usually, it's ok to finish the test scenario without reverting 51 | # to the addon's original dependency state, skipping "cleanup". 52 | - node_modules/.bin/ember try:one $EMBER_TRY_SCENARIO --skip-cleanup 53 | 54 | after_script: 55 | - greenkeeper-lockfile-upload 56 | -------------------------------------------------------------------------------- /config/ember-try.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getChannelURL = require('ember-source-channel-url'); 4 | 5 | module.exports = function() { 6 | return Promise.all([ 7 | getChannelURL('release'), 8 | getChannelURL('beta'), 9 | getChannelURL('canary') 10 | ]).then((urls) => { 11 | return { 12 | useYarn: true, 13 | scenarios: [ 14 | { 15 | name: 'ember-lts-2.12', 16 | npm: { 17 | devDependencies: { 18 | 'ember-source': '~2.12.0' 19 | } 20 | } 21 | }, 22 | { 23 | name: 'ember-lts-2.16', 24 | npm: { 25 | devDependencies: { 26 | 'ember-source': '~2.16.0' 27 | } 28 | } 29 | }, 30 | { 31 | name: 'ember-lts-2.18', 32 | npm: { 33 | devDependencies: { 34 | 'ember-source': '~2.18.0' 35 | } 36 | } 37 | }, 38 | { 39 | name: 'ember-release', 40 | npm: { 41 | devDependencies: { 42 | 'ember-source': urls[0] 43 | } 44 | } 45 | }, 46 | { 47 | name: 'ember-beta', 48 | npm: { 49 | devDependencies: { 50 | 'ember-source': urls[1] 51 | } 52 | } 53 | }, 54 | { 55 | name: 'ember-canary', 56 | npm: { 57 | devDependencies: { 58 | 'ember-source': urls[2] 59 | } 60 | } 61 | }, 62 | { 63 | name: 'ember-default', 64 | npm: { 65 | devDependencies: {} 66 | } 67 | } 68 | ] 69 | }; 70 | }); 71 | }; 72 | -------------------------------------------------------------------------------- /addon-test-support/assert-interactivity.js: -------------------------------------------------------------------------------- 1 | import { getOwner } from '@ember/application'; 2 | import QUnit from 'qunit'; 3 | 4 | function _getOwner() { 5 | let context = QUnit.config.current.testEnvironment; 6 | 7 | // getOwner(context) is set by ember-qunit for integration and unit tests 8 | // context.owner is set by our tests/helpers/module-for-acceptance.js for acceptance tests 9 | return getOwner(context) || context.owner; 10 | } 11 | 12 | QUnit.assert.trackNonInteractivity = function (subscriberName) { 13 | let service = _getOwner().lookup('service:interactivity'); 14 | 15 | let reported = service._reportedSubscribers.findBy('name', subscriberName); 16 | 17 | this.pushResult({ 18 | result: !reported, 19 | actual: reported, 20 | expected: 'undefined', 21 | message: `${subscriberName} reported interactive when it should not have` 22 | }); 23 | }; 24 | 25 | QUnit.assert.trackInteractivity = function (subscriberName, { count }={}) { 26 | let service = _getOwner().lookup('service:interactivity'); 27 | 28 | let reported = service._reportedSubscribers.filter((subscriber) => { 29 | return subscriber.name === subscriberName; 30 | }).map((subscriber) => subscriber.name); 31 | 32 | let result, message; 33 | 34 | if (count) { 35 | result = reported.length === count; 36 | if (!result) { 37 | message = `Expected ${subscriberName} to report interactive at least ${count} times`; 38 | } 39 | } else { 40 | result = !!reported.length; 41 | if (!result) { 42 | message = `${subscriberName} did not report interactive`; 43 | } 44 | } 45 | 46 | this.pushResult({ 47 | result, 48 | actual: reported, 49 | expected: `[ ${count || 1} x ${subscriberName} ]`, 50 | message 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(environment) { 4 | let ENV = { 5 | modulePrefix: 'dummy', 6 | environment, 7 | rootURL: '/', 8 | locationType: 'auto', 9 | EmberENV: { 10 | FEATURES: { 11 | // Here you can enable experimental features on an ember canary build 12 | // e.g. 'with-controller': true 13 | }, 14 | EXTEND_PROTOTYPES: { 15 | // Prevent Ember Data from overriding Date.parse. 16 | Date: false 17 | } 18 | }, 19 | 20 | metricsAdapters: [{ 21 | name: 'Mixpanel', 22 | environments: ['all'], 23 | config: { 24 | token: '028ea13aa27ef5a62bb2fe1ef5d8cc1f' 25 | } 26 | }], 27 | 28 | // Ember Interactivity Options 29 | interactivity: { 30 | instrumentation: { 31 | disableComponents: false, 32 | disableRoutes: false, 33 | }, 34 | timelineMarking: { 35 | disableComponents: false, 36 | disableLeafComponents: false, 37 | disableRoutes: false, 38 | disableParentRoutes: false 39 | }, 40 | tracking: { 41 | disableComponents: false, 42 | disableLeafComponents: false, 43 | disableRoutes: false, 44 | disableParentRoutes: false 45 | } 46 | }, 47 | 48 | APP: { 49 | // Here you can pass flags/options to your application instance 50 | // when it is created 51 | } 52 | }; 53 | 54 | if (environment === 'development') { 55 | // ENV.APP.LOG_RESOLVER = true; 56 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 57 | // ENV.APP.LOG_TRANSITIONS = true; 58 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 59 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 60 | } 61 | 62 | if (environment === 'test') { 63 | // Testem prefers this... 64 | ENV.locationType = 'none'; 65 | 66 | // keep test console output quieter 67 | ENV.APP.LOG_ACTIVE_GENERATION = false; 68 | ENV.APP.LOG_VIEW_LOOKUPS = false; 69 | 70 | ENV.APP.rootElement = '#ember-testing'; 71 | ENV.APP.autoboot = false; 72 | } 73 | 74 | if (environment === 'production') { 75 | // here you can enable a production-specific feature 76 | } 77 | 78 | return ENV; 79 | }; 80 | -------------------------------------------------------------------------------- /tests/integration/components/interactivity-beacon-test.js: -------------------------------------------------------------------------------- 1 | import { module } from 'ember-qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { clearRender, render } from '@ember/test-helpers'; 4 | import hbs from 'htmlbars-inline-precompile'; 5 | import test from 'ember-sinon-qunit/test-support/test'; 6 | import MockInteractivityService from 'ember-interactivity/test-support/mock-interactivity-service'; 7 | import MockInteractivityTrackingService from 'ember-interactivity/test-support/mock-interactivity-tracking-service'; 8 | 9 | module('Integration | Component | interactivity beacon', function (hooks) { 10 | setupRenderingTest(hooks); 11 | 12 | hooks.beforeEach(function () { 13 | this.owner.register('service:interactivity', MockInteractivityService); 14 | this.owner.register('service:interactivity-tracking', MockInteractivityTrackingService); 15 | }); 16 | 17 | test('Beacon registers itself on render', async function (assert) { 18 | assert.expect(5); 19 | 20 | let interactivityService = this.owner.__container__.lookup('service:interactivity'); 21 | let didReporterBecomeInteractiveSpy = this.spy(interactivityService, 'didReporterBecomeInteractive'); 22 | let didReporterBecomeNonInteractiveSpy = this.spy(interactivityService, 'didReporterBecomeNonInteractive'); 23 | 24 | this.set('beaconId', 'myBeaconId'); 25 | await render(hbs('{{interactivity-beacon beaconId=beaconId}}')); 26 | 27 | assert.ok(didReporterBecomeInteractiveSpy.calledOnce, 'beacon called didReporterBecomeInteractive on render'); 28 | assert.equal(didReporterBecomeInteractiveSpy.getCalls()[0].args[0].get('_latencyReportingName'), 'beacon:myBeaconId', 'beacon called didReporterBecomeInteractive with the correct arguments'); 29 | assert.notOk(didReporterBecomeNonInteractiveSpy.calledOnce, 'beacon has not called didReporterBecomeNonInteractive while rendered'); 30 | 31 | await clearRender(); 32 | assert.ok(didReporterBecomeNonInteractiveSpy.calledOnce, 'beacon called didReporterBecomeNonInteractive on unrender'); 33 | assert.equal(didReporterBecomeNonInteractiveSpy.getCalls()[0].args[0].get('_latencyReportingName'), 'beacon:myBeaconId', 'beacon called didReporterBecomeNonInteractive with the correct arguments'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-interactivity", 3 | "version": "1.2.0", 4 | "description": "Latency Tracking for Ember Applications", 5 | "keywords": [ 6 | "ember-addon", 7 | "latency", 8 | "interactivity", 9 | "metrics" 10 | ], 11 | "repository": "https://www.github.com/elwayman02/ember-interactivity", 12 | "license": "MIT", 13 | "author": "Jordan Hawker (http://www.JordanHawker.com)", 14 | "directories": { 15 | "doc": "doc", 16 | "test": "tests" 17 | }, 18 | "scripts": { 19 | "build": "ember build", 20 | "lint:js": "eslint .", 21 | "start": "ember serve", 22 | "test": "ember test", 23 | "test:all": "ember try:each" 24 | }, 25 | "dependencies": { 26 | "ember-cli-babel": "^7.1.0", 27 | "ember-cli-htmlbars": "^3.0.0", 28 | "ember-is-fastboot": "^0.1.1", 29 | "ember-is-visible": "^0.2.0" 30 | }, 31 | "devDependencies": { 32 | "@ember-decorators/babel-transforms": "^2.0.0", 33 | "broccoli-asset-rev": "^3.0.0", 34 | "ember-ajax": "^3.0.0", 35 | "ember-cli": "~3.1.4", 36 | "ember-cli-addon-docs": "elwayman02/ember-cli-addon-docs#hero-yield", 37 | "ember-cli-app-version": "^3.1.3", 38 | "ember-cli-dependency-checker": "^3.0.0", 39 | "ember-cli-eslint": "^4.2.1", 40 | "ember-cli-htmlbars-inline-precompile": "^1.0.0", 41 | "ember-cli-inject-live-reload": "^1.4.1", 42 | "ember-cli-qunit": "^4.3.2", 43 | "ember-cli-release": "1.0.0-beta.2", 44 | "ember-cli-shims": "^1.2.0", 45 | "ember-cli-sri": "^2.1.0", 46 | "ember-cli-uglify": "^2.0.0", 47 | "ember-decorators": "^2.4.1", 48 | "ember-disable-prototype-extensions": "^1.1.2", 49 | "ember-export-application-global": "^2.0.0", 50 | "ember-load-initializers": "^1.1.0", 51 | "ember-maybe-import-regenerator": "^0.1.6", 52 | "ember-metrics": "^0.12.1", 53 | "ember-resolver": "^5.0.0", 54 | "ember-sinon": "^2.1.0", 55 | "ember-sinon-qunit": "^3.3.0", 56 | "ember-source": "~3.3.0", 57 | "ember-source-channel-url": "^1.0.1", 58 | "ember-try": "^1.0.0", 59 | "eslint-plugin-ember": "^5.0.0", 60 | "eslint-plugin-node": "^7.0.1", 61 | "greenkeeper-lockfile": "^1.15.1", 62 | "loader.js": "^4.2.3", 63 | "qunit-dom": "^0.8.0" 64 | }, 65 | "engines": { 66 | "node": "6.* || 8.* || >= 10.*" 67 | }, 68 | "ember-addon": { 69 | "configPath": "tests/dummy/config", 70 | "demoURL": "http://jhawk.co/interactivity-demo" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/index.hbs: -------------------------------------------------------------------------------- 1 | {{#docs-hero 2 | slimHeading='Ember' 3 | strongHeading='Interactivity' 4 | byline='Latency Metrics for User-Perceived Load Times' 5 | yieldMultiple=true 6 | as |yieldType| 7 | }} 8 | {{#if yieldType.isLogo}} 9 | 12 | {{else if yieldType.isBottom}} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |

Version {{app-version hideSha=true}}

29 | {{/if}} 30 | {{/docs-hero}} 31 | 32 |
33 |
34 |

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 | ![Ember Interactivity](docs/hero-logo.png) 2 | ========================================== 3 | 4 | [![Netlify Status](https://api.netlify.com/api/v1/badges/233e41f6-8b57-4f43-9622-a772edf9f244/deploy-status)](https://app.netlify.com/sites/optimistic-booth-95742f/deploys) 5 | [![Build Status](https://travis-ci.org/elwayman02/ember-interactivity.svg?branch=master)](https://travis-ci.org/elwayman02/ember-interactivity) 6 | [![Ember Observer Score](https://emberobserver.com/badges/ember-interactivity.svg)](https://emberobserver.com/addons/ember-interactivity) 7 | [![Code Climate](https://codeclimate.com/github/elwayman02/ember-interactivity/badges/gpa.svg)](https://codeclimate.com/github/elwayman02/ember-interactivity) 8 | [![Greenkeeper badge](https://badges.greenkeeper.io/elwayman02/ember-interactivity.svg)](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 | ![Component Waterfall](docs/waterfall.png) 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 | [![Powered By Netlify](https://www.netlify.com/img/global/badges/netlify-light.svg)](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 | --------------------------------------------------------------------------------