Skip to content

Instantly share code, notes, and snippets.

@adamrneary
Created September 23, 2017 22:21
Show Gist options
  • Save adamrneary/8b0ed25c512345597ac3fc4c64900130 to your computer and use it in GitHub Desktop.
Save adamrneary/8b0ed25c512345597ac3fc4c64900130 to your computer and use it in GitHub Desktop.
/* eslint react/forbid-foreign-prop-types: 1 */
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { PageName } from 'airbnb-jitney-schemas/logging_type_page_name_v1';
import AirbnbInteractiveLogger, {
NEVER_INTERACTIVE,
VALIDATED_AIRBNB_INTERACTIVE,
} from '../utils/AirbnbInteractiveLogger';
export const withAirbnbInteractivePropTypes = {
initializeAirbnbInteractive: PropTypes.func,
interactive: PropTypes.bool,
};
const childContextTypes = {
pageName: PropTypes.oneOf(Object.values(PageName)),
};
/**
* HOC that passes two important props to the wrapped component:
* - initializeAirbnbInteractive (func)
* - interactive (bool)
*
* `initializeAirbnbInteractive` should be called in the constructor of the wrapped component.
* initializeAirbnbInteractive({
* pageName: PageName.PdpHomeLuxury,
* isInteractive: () => !!this.props.listing,
* info: { filters: { guests: 3 } },
* });
*
* The function logs two events:
* - Impression fires as early as possible and contains no performance data
* - Time To Interactive (TTI) is our primary performance measurement.
*
* Arguments for initializeAirbnbInteractive:
* - pageName (required value of airbnb-jitney-schemas/logging_type_page_name_v1)
* - isInteractive (optional function) bound to the wrapped component, defines conditions in
* which the component is interactive. If nothing is specified (this is rare), TTI fires on
* componentDidMount. If specified, it is evaluates on componentDidMount and
* componentDidUpdate until the component registers as interactive
*
* The interactive prop passed to the wrapped component indicates if the component has yet
* triggered an interactive state. This prop can be used to hide content below the fold or defer
* expensive but non-critical actions.
*
*/
export default function withAirbnbInteractive({
confirmIsInteractive,
extraEventData = {},
universalPageName,
}) {
if (!universalPageName) {
throw new Error('AirbnbInteractive: Cannot log without page name provided.');
}
if (!Object.values(PageName).includes(universalPageName)) {
throw new Error(`AirbnbInteractive: Cannot log with invalid page name: ${universalPageName}.`);
}
AirbnbInteractiveLogger.logImpression({ universalPageName, extraEventData });
return function withAirbnbInteractiveHOC(WrappedComponent) {
class WithAirbnbInteractive extends Component {
constructor(props) {
super(props);
this.state = {
isInteractive: false,
};
this.wasSetInteractive = false;
this.setInteractive = this.setInteractive.bind(this);
this.startPageTransition = this.startPageTransition.bind(this);
}
getChildContext() {
return {
universalPageName,
};
}
componentDidMount() {
const { history } = this.props;
if (history) {
this.unlistenHistory = history.listen((location, action) => {
if (action === 'PUSH' || action === 'POP') {
this.startPageTransition({ universalPageName, extraEventData });
}
});
}
this.setInteractive();
}
componentDidUpdate() {
if (!this.state.isInteractive) {
this.setInteractive();
}
}
componentWillUnmount() {
if (!this.state.isInteractive) {
AirbnbInteractiveLogger.logAirbnbInteractive({
methodology: NEVER_INTERACTIVE,
universalPageName,
extraEventData,
});
}
if (this.timeout) {
window.clearTimeout(this.timeout);
}
if (this.unlistenHistory) {
this.unlistenHistory();
}
}
setInteractive() {
const { isInteractive } = this.state;
if (this.wasSetInteractive || !confirmIsInteractive()) {
return;
}
// There is a time gap between when we log interactive and when we
// setState to interactive. So keep track of when we've logged so we don't
// double count.
this.wasSetInteractive = true;
this.timeout = setTimeout(() => {
AirbnbInteractiveLogger.logAirbnbInteractive({
methodology: VALIDATED_AIRBNB_INTERACTIVE,
extraEventData,
universalPageName,
});
this.timeout = setTimeout(() => {
this.timeout = null;
// For any particular instance of this HOC, we only set `interactive`
// to true ONCE. This is important because if there's a page
// transition on the same page, we want to log the interactive time
// for it (so we need to keep track of when interactivity was reset
// via `this.wasSetInteractive` but we don't actually want to change
// the value of the `interactive` prop because this would cause
// things that have already rendered post-interactive to unrender
// then re-render (e.g. Google Maps) which is bad.
if (!isInteractive) {
this.setState({ isInteractive: true });
}
});
});
}
startPageTransition() {
this.wasSetInteractive = false;
AirbnbInteractiveLogger.logStartPageTransition();
}
render() {
return (
<WrappedComponent
{...this.props}
interactive={this.state.interactive}
initializeAirbnbInteractive={this.initializeAirbnbInteractive}
/>
);
}
}
const wrappedComponentName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
// eslint-disable-next-line react/forbid-foreign-prop-types
if (WrappedComponent.propTypes) {
WithAirbnbInteractive.propTypes = {
// eslint-disable-next-line react/forbid-foreign-prop-types
...WrappedComponent.propTypes,
};
}
if (WrappedComponent.defaultProps) {
WithAirbnbInteractive.defaultProps = {
...WrappedComponent.defaultProps,
};
}
WithAirbnbInteractive.displayName = `WithAirbnbInteractive(${wrappedComponentName})`;
WithAirbnbInteractive.childContextTypes = childContextTypes;
return WithAirbnbInteractive;
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment