next-rum
Version:
RUM Component for Next.js
483 lines (405 loc) • 15.8 kB
JavaScript
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
var _purrformance = require('./purrformance');
var _purrformance2 = _interopRequireDefault(_purrformance);
var _react = require('react');
var _react2 = _interopRequireDefault(_react);
var _propTypes = require('prop-types');
var _propTypes2 = _interopRequireDefault(_propTypes);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
// eslint-disable-next-line no-unused-vars
/**
* Measure RUM timing for Next.js based applications.
*
* @class
* @public
*/
var Measure = function (_Component) {
_inherits(Measure, _Component);
function Measure() {
_classCallCheck(this, Measure);
var _this = _possibleConstructorReturn(this, (Measure.__proto__ || Object.getPrototypeOf(Measure)).apply(this, arguments));
_this.timeOrigin = (0, _purrformance.timeOrigin)(); // Start of the original navigation.
_this.emitter = null; // Reference to next.emitter.
_this.router = null; // Reference to next.router.
_this.timings = {}; // Store timing data.
_this.timer = null; // Reference to a timer.
//
// Pre-bind all the methods that are passed around.
//
['before', 'after', 'start', 'complete', 'payload', 'flush'].forEach(function (name) {
return _this[name] = _this[name].bind(_this);
});
//
// Check if we need to increase the timing buffer, for most browsers there
// is already a decent size of 150~ set as buffer but for some more extreme
// cases you might want to track more.
//
var size = _this.props.setResourceTimingBufferSize;
if (typeof size === 'number') {
(0, _purrformance2.default)('setResourceTimingBufferSize', size);
}
return _this;
}
/**
* When the component is mounted, we know that the `next` library has been
* loaded and we can hook into.
*
* @private
*/
_createClass(Measure, [{
key: 'componentDidMount',
value: function componentDidMount() {
var _global$next = global.next,
emitter = _global$next.emitter,
router = _global$next.router;
//
// The render flow of a Next based application based on the sequence of
// events found in the `client/*` folder of the next repository.
//
// 1. `routeChangeStart` router event is emitted.
// 2. Route information is requested, if this is not cached or cacheable:
// - Fetch the component from the server using `document.createElement(script)`
// if not previously cached.
// - Execute `getInitialProps` on the component to fetch props/data for render.
// 3. `beforeHistoryChange` router event is emitted.
// 4. Browser `window.history` is updated.
// 5. Router properties such as `asPath` are updated.
// 6. Notify all router subscription of the change which triggers `next.render`
// - `before-reactdom-render` next event is emitted.
// - `after-reactdom-render` next event is emitted.
// 7. `routeChangeComplete` router event is emitted.
//
router.events.on('routeChangeStart', this.start);
emitter.on('before-reactdom-render', this.before);
emitter.on('after-reactdom-render', this.after);
router.events.on('routeChangeComplete', this.complete);
if (this.props.delay && global.addEventListener) {
global.addEventListener('beforeunload', this.flush);
}
this.emitter = emitter;
this.router = router;
}
/**
* Component is about to unmount, remove all the hooks we've placed on the
* Next.js internals.
*
* @private
*/
}, {
key: 'componentWillUnmount',
value: function componentWillUnmount() {
var emitter = this.emitter,
router = this.router,
props = this.props;
//
// Before we completely destroy our references, check if we have a current
// buffer that should be flushed.
//
this.flush();
router.events.off('routeChangeStart', this.start);
emitter.off('before-reactdom-render', this.before);
emitter.off('after-reactdom-render', this.after);
router.events.off('routeChangeComplete', this.complete);
if (props.delay && global.removeEventListener) {
global.removeEventListener('beforeunload', this.flush);
}
this.emitter = this.router = null;
}
/**
* Set new timing information.
*
* @param {String} name Name of the timing event.
* @param {Object} data Additional information.
* @public
*/
}, {
key: 'set',
value: function set(name, data) {
this.timings[name] = _extends({}, data, {
now: Date.now()
});
}
/**
* Find a stat for a given name.
*
* @param {String} name Name of the metrict we want to read.
* @returns {Object|Undefined} The additional timing info.
* @public
*/
}, {
key: 'get',
value: function get(name) {
return this.timings[name];
}
/**
* Forcefully flush any gathered metrics that we've gathered. Even if we
* are asked to delay the gathering. This will be done incase of unloading
* of the page, so metrics can still be send if needed.
*
* @returns {undefined} Nothing.
* @private
*/
}, {
key: 'flush',
value: function flush() {
if (!this.timer) return this.reset();
this.payload();
}
/**
* Reset out `timings` tracking object to nothing.
*
* @public
*/
}, {
key: 'reset',
value: function reset() {
clearTimeout(this.timer);
this.timer = null;
this.timings = {};
}
/**
* Responds to the `before-reactdom-render` call as DOM loading as this call
* will unmount any previous components, clearing up the DOM, ready for
* rendering.
*
* appProps.err will indicate if there was error previously during rendering
* so there might be multiple before calls.
*
* @private
*/
}, {
key: 'before',
value: function before() /* { Component, ErrorComponent, appProps } */{
//
// It's possible that we get an error while rendering the application.
//
// - Error is thrown during rendering
// - Error triggers, ErrorBoundry of Next
// - ErrorBoundry triggers RenderError
// - Sets ErrorComponent as Component
// - Calls render again, here we are with appProps.err set and another
// `before-reactdom-render` attempt.
//
// So we don't want to override an existing `domLoading` event that
// we already set, because then we will have the time of when the error
// is rendered, not when we first started to render.
//
if (this.get('domLoading')) return;
this.set('domLoading');
}
/**
* Responds to the `after-reactdom-render` call, the component has been
* mounted in the DOM.
*
* @private
*/
}, {
key: 'after',
value: function after() /* { Component, ErrorComponent, appProps } */{
//
// It is worth noting, that we do not case how many times this called
// unliked the `before` method, as we **want** to override the timing
// information with the latest call.
//
this.set('domContentLoaded');
}
/**
* The `routeChangeStart` event is called, so we are about to fetch and
* navigate to a different URL.
*
* @param {String} url The URL we're about to load.
* @private
*/
}, {
key: 'start',
value: function start(url) {
//
// Check if we already have data queued, if that is the case we want to
// make sure that we flush it, and reset our metrics.
//
this.flush();
// Clearning the resourceTimings does a couple of useful things for us:
//
// 1. It ensures that we do not overflow our resource buffer. Browsers have
// a fixed limit of the amount of resources they can track. By clearning
// it on the start we reduce free up memory, and allow all requests that
// are made during the navigation phase being captured.
// 2. We have to track and check less performance entries once we are done
// so we can safely assume that the first request that is in the entries
// will be the start of our request.
//
if (this.props.clearResourceTimings) {
(0, _purrformance2.default)('clearResourceTimings');
}
this.set('navigationStart', { url: url });
}
/**
* The `routeChangeComplete` event is called.
*
* @param {String} url The URL we've just loaded.
* @private
*/
}, {
key: 'complete',
value: function complete(url) {
var delay = this.props.delay;
this.set('loadEventEnd', { url: url });
//
// The performance ResourceAPI only contains files that are fully loaded,
// items that are in flight are not included. So when a page loads images
// after the page is rendered, we want to capture those as well as last
//
if (delay) {
clearTimeout(this.timer);
this.timer = setTimeout(this.payload, delay);
} else {
this.payload();
}
}
/**
* Grab all ResourceAPI entries and see if we can extract relevant data
* from it to make the timing information more accurate.
*
* @param {Object} range Start and end time in which the requests could start.
* @param {Object} rum The RUM timing object that we can improve.
* @returns {Array} resources The items that are loaded during the navigation.
* @public
*/
}, {
key: 'resourceTiming',
value: function resourceTiming(range, rum) {
var resources = (0, _purrformance.entries)(range);
var page = (0, _purrformance.find)(resources, /\/_next\/-\/page\/(.*)\.js$/g);
//
// We can use the request that fetches the JavaScript bundle that contains
// the page component as starting/end time of the request. It's still
// missing the time it took to fetch `getInitialProps` on the component,
// but still an improvement over the normal metrics
//
if (page) {
if (page.responseStart) rum.responseStart = page.responseStart;
if (page.responseEnd) rum.responseEnd = page.responseEnd;
}
//
// The `loadEventStart` should be the same as the `domComplete` time as
// that is when the resources can start with loading. To more accurately
// estimate the `loadEventEnd` we can see it the last resource that is
// loaded on the page end later our basic rum timing and use that instead.
//
var last = resources[resources.length - 1];
if (last && last.responseEnd > rum.loadEventEnd) {
rum.loadEventEnd = last.responseEnd;
}
return resources;
}
/**
* Create the payload that is send to the callback.
*
* @returns {undefined} Nothing
* @private
*/
// eslint-disable-next-line complexity
}, {
key: 'payload',
value: function payload() {
var unmount = this.get('domLoading'),
rendered = Measure.webVitals.loadEventStart,
start = Measure.webVitals.navigationStart,
end = Measure.webVitals.loadEventEnd,
rum = {};
if (!start || !end || !rendered) return this.reset();
//
// Start of the route loading.
//
['navigationStart', // `routeChangeStart` event.
'fetchStart', //
'domainLookupStart', // These are all not trackable with Next
'domainLookupEnd', // because we cannot hook into their component
'connectStart', // download and getInitialProps.
'connectEnd', //
'requestStart', // So we are going to default all of these
'responseStart', // to the start timing for now until we
'responseEnd' // made a PR to add events for these.
].forEach(function (name) {
return rum[name] = start;
});
//
// Components and data are fetched.
//
rum.domLoading = unmount && unmount.now ? unmount.now : null;
['domInteractive', // Unable to measure, SPA's are always interactive
'domContentLoaded', // Once the React app is rendered, it is loaded
'domComplete', // and also complete, so use the same timing.
'loadEventStart' // loadEventStart should be the same as domComplete
].forEach(function (name) {
return rum[name] = rendered;
});
rum.loadEventEnd = end;
//
// Check if we can use the ResourceAPI to improvement some our data.
//
var timings = this.resourceTiming({ start: start, end: end }, rum);
this.props.navigated(this.router.asPath, rum, timings);
this.reset();
}
/**
* Wraps all the components, so we're just going to return the
* children.
*
* @returns {Children} The child components.
* @private
*/
}, {
key: 'render',
value: function render() {
return this.props.children || null;
}
}]);
return Measure;
}(_react.Component);
/**
* We need to expose these properties to be updated with performance metrics from Next.js built in reportWebVitals
* function.
*
* @type {Object}
*/
exports.default = Measure;
Measure.webVitals = {
navigationStart: null,
loadEventStart: null,
loadEventEnd: null,
renderDuration: null
};
/**
* Default props.
*
* @type {Object}
* @private
*/
Measure.defaultProps = {
clearResourceTimings: true,
unload: true,
delay: 2000
};
/**
* Ensure that we've received the correct props.
*
* @type {Object}
* @private
*/
Measure.propTypes = {
setResourceTimingBufferSize: _propTypes2.default.number,
navigated: _propTypes2.default.func.isRequired,
clearResourceTimings: _propTypes2.default.bool,
children: _propTypes2.default.node,
delay: _propTypes2.default.number,
unload: _propTypes2.default.bool
};