Browse Source

ref(query-client): Deprecate AsyncComponent and AsyncView (#52550)

`AsyncComponent` and `AsyncView` are no longer recommended for future
use. They require class components (use functional components instead!),
depend on inheritance, and do not include request caching.

The [docs page on network
requests](https://develop.sentry.dev/frontend/network-requests/#quick-start)
should have all the info necessary for devs to use `useApiQuery` as an
alternative.
Malachi Willey 1 year ago
parent
commit
d0492d6817

+ 2 - 2
babel.config.ts

@@ -37,8 +37,8 @@ const config: TransformOptions = {
             classNameMatchers: [
               'SelectField',
               'FormField',
-              'AsyncComponent',
-              'AsyncView',
+              'DeprecatedAsyncComponent',
+              'DeprecatedAsyncView',
             ],
             additionalLibraries: [/app\/sentryTypes$/],
           },

+ 1 - 465
static/app/components/asyncComponent.tsx

@@ -1,465 +1 @@
-import {Component} from 'react';
-import {RouteComponentProps} from 'react-router';
-import * as Sentry from '@sentry/react';
-import isEqual from 'lodash/isEqual';
-import * as PropTypes from 'prop-types';
-
-import {Client, ResponseMeta} from 'sentry/api';
-import AsyncComponentSearchInput from 'sentry/components/asyncComponentSearchInput';
-import LoadingError from 'sentry/components/loadingError';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import {t} from 'sentry/locale';
-import {metric} from 'sentry/utils/analytics';
-import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
-import PermissionDenied from 'sentry/views/permissionDenied';
-import RouteError from 'sentry/views/routeError';
-
-export interface AsyncComponentProps extends Partial<RouteComponentProps<{}, {}>> {}
-
-export interface AsyncComponentState {
-  [key: string]: any;
-  error: boolean;
-  errors: Record<string, ResponseMeta>;
-  loading: boolean;
-  reloading: boolean;
-  remainingRequests?: number;
-}
-
-type SearchInputProps = React.ComponentProps<typeof AsyncComponentSearchInput>;
-
-type RenderSearchInputArgs = Omit<
-  SearchInputProps,
-  'api' | 'onSuccess' | 'onError' | 'url' | keyof RouteComponentProps<{}, {}>
-> & {
-  stateKey?: string;
-  url?: SearchInputProps['url'];
-};
-
-/**
- * Wraps methods on the AsyncComponent to catch errors and set the `error`
- * state on error.
- */
-function wrapErrorHandling<T extends any[], U>(
-  component: AsyncComponent,
-  fn: (...args: T) => U
-) {
-  return (...args: T): U | null => {
-    try {
-      return fn(...args);
-    } catch (error) {
-      // eslint-disable-next-line no-console
-      console.error(error);
-      window.setTimeout(() => {
-        throw error;
-      });
-      component.setState({error});
-      return null;
-    }
-  };
-}
-
-class AsyncComponent<
-  P extends AsyncComponentProps = AsyncComponentProps,
-  S extends AsyncComponentState = AsyncComponentState
-> extends Component<P, S> {
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
-  constructor(props: P, context: any) {
-    super(props, context);
-
-    this.api = new Client();
-    this.fetchData = wrapErrorHandling(this, this.fetchData.bind(this));
-    this.render = wrapErrorHandling(this, this.render.bind(this));
-
-    this.state = this.getDefaultState() as Readonly<S>;
-
-    this._measurement = {
-      hasMeasured: false,
-    };
-    if (props.routes) {
-      metric.mark({name: `async-component-${getRouteStringFromRoutes(props.routes)}`});
-    }
-  }
-
-  componentDidMount() {
-    this.fetchData();
-
-    if (this.reloadOnVisible) {
-      document.addEventListener('visibilitychange', this.visibilityReloader);
-    }
-  }
-
-  componentDidUpdate(prevProps: P, prevContext: any) {
-    const isRouterInContext = !!prevContext.router;
-    const isLocationInProps = prevProps.location !== undefined;
-
-    const currentLocation = isLocationInProps
-      ? this.props.location
-      : isRouterInContext
-      ? this.context.router.location
-      : null;
-    const prevLocation = isLocationInProps
-      ? prevProps.location
-      : isRouterInContext
-      ? prevContext.router.location
-      : null;
-
-    if (!(currentLocation && prevLocation)) {
-      return;
-    }
-
-    // Take a measurement from when this component is initially created until it finishes it's first
-    // set of API requests
-    if (
-      !this._measurement.hasMeasured &&
-      this._measurement.finished &&
-      this.props.routes
-    ) {
-      const routeString = getRouteStringFromRoutes(this.props.routes);
-      metric.measure({
-        name: 'app.component.async-component',
-        start: `async-component-${routeString}`,
-        data: {
-          route: routeString,
-          error: this._measurement.error,
-        },
-      });
-      this._measurement.hasMeasured = true;
-    }
-
-    // Re-fetch data when router params change.
-    if (
-      !isEqual(this.props.params, prevProps.params) ||
-      currentLocation.search !== prevLocation.search ||
-      currentLocation.state !== prevLocation.state
-    ) {
-      this.remountComponent();
-    }
-  }
-
-  componentWillUnmount() {
-    this.api.clear();
-    document.removeEventListener('visibilitychange', this.visibilityReloader);
-  }
-
-  /**
-   * Override this flag to have the component reload its state when the window
-   * becomes visible again. This will set the loading and reloading state, but
-   * will not render a loading state during reloading.
-   *
-   * eslint-disable-next-line react/sort-comp
-   */
-  reloadOnVisible = false;
-
-  /**
-   * When enabling reloadOnVisible, this flag may be used to turn on and off
-   * the reloading. This is useful if your component only needs to reload when
-   * becoming visible during certain states.
-   *
-   * eslint-disable-next-line react/sort-comp
-   */
-  shouldReloadOnVisible = false;
-
-  /**
-   * This affects how the component behaves when `remountComponent` is called
-   * By default, the component gets put back into a "loading" state when re-fetching data.
-   * If this is true, then when we fetch data, the original ready component remains mounted
-   * and it will need to handle any additional "reloading" states
-   */
-  shouldReload = false;
-
-  /**
-   * should `renderError` render the `detail` attribute of a 400 error
-   */
-  shouldRenderBadRequests = false;
-
-  /**
-   * If a request fails and is not a bad request, and if `disableErrorReport` is set to false,
-   * the UI will display an error modal.
-   *
-   * It is recommended to enable this property ideally only when the subclass is used by a top level route.
-   */
-  disableErrorReport = true;
-
-  api: Client = new Client();
-  private _measurement: any;
-
-  // XXX: can't call this getInitialState as React whines
-  getDefaultState(): AsyncComponentState {
-    const endpoints = this.getEndpoints();
-
-    const state = {
-      // has all data finished requesting?
-      loading: true,
-      // is the component reload
-      reloading: false,
-      // is there an error loading ANY data?
-      error: false,
-      errors: {},
-      // We will fetch immeditaely upon mount
-      remainingRequests: endpoints.length || undefined,
-    };
-
-    // We are not loading if there are no endpoints
-    if (!endpoints.length) {
-      state.loading = false;
-    }
-
-    endpoints.forEach(([stateKey, _endpoint]) => {
-      state[stateKey] = null;
-    });
-    return state;
-  }
-
-  // Check if we should measure render time for this component
-  markShouldMeasure = ({
-    remainingRequests,
-    error,
-  }: {error?: any; remainingRequests?: number} = {}) => {
-    if (!this._measurement.hasMeasured) {
-      this._measurement.finished = remainingRequests === 0;
-      this._measurement.error = error || this._measurement.error;
-    }
-  };
-
-  remountComponent = () => {
-    if (this.shouldReload) {
-      this.reloadData();
-    } else {
-      this.setState(this.getDefaultState(), this.fetchData);
-    }
-  };
-
-  visibilityReloader = () =>
-    this.shouldReloadOnVisible &&
-    !this.state.loading &&
-    !document.hidden &&
-    this.reloadData();
-
-  reloadData() {
-    this.fetchData({reloading: true});
-  }
-
-  fetchData = (extraState?: object) => {
-    const endpoints = this.getEndpoints();
-
-    if (!endpoints.length) {
-      this.setState({loading: false, error: false});
-      return;
-    }
-
-    // Cancel any in flight requests
-    this.api.clear();
-
-    this.setState({
-      loading: true,
-      error: false,
-      remainingRequests: endpoints.length,
-      ...extraState,
-    });
-
-    endpoints.forEach(([stateKey, endpoint, params, options]) => {
-      options = options || {};
-      // If you're using nested async components/views make sure to pass the
-      // props through so that the child component has access to props.location
-      const locationQuery = (this.props.location && this.props.location.query) || {};
-      let query = (params && params.query) || {};
-      // If paginate option then pass entire `query` object to API call
-      // It should only be expecting `query.cursor` for pagination
-      if ((options.paginate || locationQuery.cursor) && !options.disableEntireQuery) {
-        query = {...locationQuery, ...query};
-      }
-
-      this.api.request(endpoint, {
-        method: 'GET',
-        ...params,
-        query,
-        success: (data, _, resp) => {
-          this.handleRequestSuccess({stateKey, data, resp}, true);
-        },
-        error: error => {
-          // Allow endpoints to fail
-          // allowError can have side effects to handle the error
-          if (options.allowError && options.allowError(error)) {
-            error = null;
-          }
-          this.handleError(error, [stateKey, endpoint, params, options]);
-        },
-      });
-    });
-  };
-
-  onRequestSuccess(_resp /* {stateKey, data, resp} */) {
-    // Allow children to implement this
-  }
-
-  onRequestError(_resp, _args) {
-    // Allow children to implement this
-  }
-
-  onLoadAllEndpointsSuccess() {
-    // Allow children to implement this
-  }
-
-  handleRequestSuccess({stateKey, data, resp}, initialRequest?: boolean) {
-    this.setState(
-      prevState => {
-        const state = {
-          [stateKey]: data,
-          // TODO(billy): This currently fails if this request is retried by SudoModal
-          [`${stateKey}PageLinks`]: resp?.getResponseHeader('Link'),
-        };
-
-        if (initialRequest) {
-          state.remainingRequests = prevState.remainingRequests! - 1;
-          state.loading = prevState.remainingRequests! > 1;
-          state.reloading = prevState.reloading && state.loading;
-          this.markShouldMeasure({remainingRequests: state.remainingRequests});
-        }
-
-        return state;
-      },
-      () => {
-        // if everything is loaded and we don't have an error, call the callback
-        if (this.state.remainingRequests === 0 && !this.state.error) {
-          this.onLoadAllEndpointsSuccess();
-        }
-      }
-    );
-    this.onRequestSuccess({stateKey, data, resp});
-  }
-
-  handleError(error, args) {
-    const [stateKey] = args;
-    if (error && error.responseText) {
-      Sentry.addBreadcrumb({
-        message: error.responseText,
-        category: 'xhr',
-        level: 'error',
-      });
-    }
-    this.setState(prevState => {
-      const loading = prevState.remainingRequests! > 1;
-      const state: AsyncComponentState = {
-        [stateKey]: null,
-        errors: {
-          ...prevState.errors,
-          [stateKey]: error,
-        },
-        error: prevState.error || !!error,
-        remainingRequests: prevState.remainingRequests! - 1,
-        loading,
-        reloading: prevState.reloading && loading,
-      };
-      this.markShouldMeasure({remainingRequests: state.remainingRequests, error: true});
-
-      return state;
-    });
-    this.onRequestError(error, args);
-  }
-
-  /**
-   * Return a list of endpoint queries to make.
-   *
-   * return [
-   *   ['stateKeyName', '/endpoint/', {optional: 'query params'}, {options}]
-   * ]
-   */
-  getEndpoints(): Array<[string, string, any?, any?]> {
-    return [];
-  }
-
-  renderSearchInput({stateKey, url, ...props}: RenderSearchInputArgs) {
-    const [firstEndpoint] = this.getEndpoints() || [null];
-    const stateKeyOrDefault = stateKey || (firstEndpoint && firstEndpoint[0]);
-    const urlOrDefault = url || (firstEndpoint && firstEndpoint[1]);
-    return (
-      <AsyncComponentSearchInput
-        url={urlOrDefault}
-        {...props}
-        api={this.api}
-        onSuccess={(data, resp) => {
-          this.handleRequestSuccess({stateKey: stateKeyOrDefault, data, resp});
-        }}
-        onError={() => {
-          this.renderError(new Error('Error with AsyncComponentSearchInput'));
-        }}
-      />
-    );
-  }
-
-  renderLoading(): React.ReactNode {
-    return <LoadingIndicator />;
-  }
-
-  renderError(error?: Error, disableLog = false): React.ReactNode {
-    const {errors} = this.state;
-
-    // 401s are captured by SudoModal, but may be passed back to AsyncComponent if they close the modal without identifying
-    const unauthorizedErrors = Object.values(errors).find(resp => resp?.status === 401);
-
-    // Look through endpoint results to see if we had any 403s, means their role can not access resource
-    const permissionErrors = Object.values(errors).find(resp => resp?.status === 403);
-
-    // If all error responses have status code === 0, then show error message but don't
-    // log it to sentry
-    const shouldLogSentry =
-      !!Object.values(errors).find(resp => resp?.status !== 0) || disableLog;
-
-    if (unauthorizedErrors) {
-      return (
-        <LoadingError message={t('You are not authorized to access this resource.')} />
-      );
-    }
-
-    if (permissionErrors) {
-      return <PermissionDenied />;
-    }
-
-    if (this.shouldRenderBadRequests) {
-      const badRequests = Object.values(errors)
-        .filter(resp => resp?.status === 400 && resp?.responseJSON?.detail)
-        .map(resp => resp.responseJSON.detail);
-
-      if (badRequests.length) {
-        return <LoadingError message={[...new Set(badRequests)].join('\n')} />;
-      }
-    }
-
-    return (
-      <RouteError
-        error={error}
-        disableLogSentry={!shouldLogSentry}
-        disableReport={this.disableErrorReport}
-      />
-    );
-  }
-
-  get shouldRenderLoading() {
-    return this.state.loading && (!this.shouldReload || !this.state.reloading);
-  }
-
-  renderComponent() {
-    return this.shouldRenderLoading
-      ? this.renderLoading()
-      : this.state.error
-      ? this.renderError()
-      : this.renderBody();
-  }
-
-  /**
-   * Renders once all endpoints have been loaded
-   */
-  renderBody(): React.ReactNode {
-    // Allow children to implement this
-    throw new Error('Not implemented');
-  }
-
-  render() {
-    return this.renderComponent();
-  }
-}
-
-export default AsyncComponent;
+export {default} from 'sentry/components/deprecatedAsyncComponent';

+ 8 - 5
static/app/components/contextPickerModal.tsx

@@ -4,7 +4,7 @@ import {components} from 'react-select';
 import styled from '@emotion/styled';
 
 import {ModalRenderProps} from 'sentry/actionCreators/modal';
-import AsyncComponent from 'sentry/components/asyncComponent';
+import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 import SelectControl, {
   StylesConfig,
 } from 'sentry/components/forms/controls/selectControl';
@@ -431,15 +431,18 @@ type ContainerProps = Omit<
    * List of slugs we want to be able to choose from
    */
   projectSlugs?: string[];
-} & AsyncComponent['props'];
+} & DeprecatedAsyncComponent['props'];
 
 type ContainerState = {
   organizations: Organization[];
   integrationConfigs?: Integration[];
   selectedOrganization?: string;
-} & AsyncComponent['state'];
+} & DeprecatedAsyncComponent['state'];
 
-class ContextPickerModalContainer extends AsyncComponent<ContainerProps, ContainerState> {
+class ContextPickerModalContainer extends DeprecatedAsyncComponent<
+  ContainerProps,
+  ContainerState
+> {
   getDefaultState() {
     const storeState = OrganizationStore.get();
     return {
@@ -449,7 +452,7 @@ class ContextPickerModalContainer extends AsyncComponent<ContainerProps, Contain
     };
   }
 
-  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+  getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
     const {configUrl} = this.props;
     if (configUrl) {
       return [['integrationConfigs', configUrl]];

+ 7 - 7
static/app/components/asyncComponent.spec.tsx → static/app/components/deprecatedAsyncComponent.spec.tsx

@@ -1,12 +1,12 @@
 import {render, screen} from 'sentry-test/reactTestingLibrary';
 
-import AsyncComponent from 'sentry/components/asyncComponent';
+import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 
-describe('AsyncComponent', function () {
-  class TestAsyncComponent extends AsyncComponent {
+describe('DeprecatedAsyncComponent', function () {
+  class TestAsyncComponent extends DeprecatedAsyncComponent {
     shouldRenderBadRequests = true;
 
-    getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+    getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
       return [['data', '/some/path/to/something/']];
     }
 
@@ -69,10 +69,10 @@ describe('AsyncComponent', function () {
       statusCode: 400,
     });
 
-    class UniqueErrorsAsyncComponent extends AsyncComponent {
+    class UniqueErrorsAsyncComponent extends DeprecatedAsyncComponent {
       shouldRenderBadRequests = true;
 
-      getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+      getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
         return [
           ['first', '/first/path/'],
           ['second', '/second/path/'],
@@ -94,7 +94,7 @@ describe('AsyncComponent', function () {
 
   describe('multi-route component', () => {
     class MultiRouteComponent extends TestAsyncComponent {
-      getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+      getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
         return [
           ['data', '/some/path/to/something/'],
           ['project', '/another/path/here'],

+ 472 - 0
static/app/components/deprecatedAsyncComponent.tsx

@@ -0,0 +1,472 @@
+import {Component} from 'react';
+import {RouteComponentProps} from 'react-router';
+import * as Sentry from '@sentry/react';
+import isEqual from 'lodash/isEqual';
+import * as PropTypes from 'prop-types';
+
+import {Client, ResponseMeta} from 'sentry/api';
+import AsyncComponentSearchInput from 'sentry/components/asyncComponentSearchInput';
+import LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {t} from 'sentry/locale';
+import {metric} from 'sentry/utils/analytics';
+import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
+import PermissionDenied from 'sentry/views/permissionDenied';
+import RouteError from 'sentry/views/routeError';
+
+export interface AsyncComponentProps extends Partial<RouteComponentProps<{}, {}>> {}
+
+export interface AsyncComponentState {
+  [key: string]: any;
+  error: boolean;
+  errors: Record<string, ResponseMeta>;
+  loading: boolean;
+  reloading: boolean;
+  remainingRequests?: number;
+}
+
+type SearchInputProps = React.ComponentProps<typeof AsyncComponentSearchInput>;
+
+type RenderSearchInputArgs = Omit<
+  SearchInputProps,
+  'api' | 'onSuccess' | 'onError' | 'url' | keyof RouteComponentProps<{}, {}>
+> & {
+  stateKey?: string;
+  url?: SearchInputProps['url'];
+};
+
+/**
+ * Wraps methods on the AsyncComponent to catch errors and set the `error`
+ * state on error.
+ */
+function wrapErrorHandling<T extends any[], U>(
+  component: DeprecatedAsyncComponent,
+  fn: (...args: T) => U
+) {
+  return (...args: T): U | null => {
+    try {
+      return fn(...args);
+    } catch (error) {
+      // eslint-disable-next-line no-console
+      console.error(error);
+      window.setTimeout(() => {
+        throw error;
+      });
+      component.setState({error});
+      return null;
+    }
+  };
+}
+
+/**
+ * @deprecated use useApiQuery instead
+ *
+ * Read the dev docs page on network requests for more information [1].
+ *
+ * [1]: https://develop.sentry.dev/frontend/network-requests/
+ */
+class DeprecatedAsyncComponent<
+  P extends AsyncComponentProps = AsyncComponentProps,
+  S extends AsyncComponentState = AsyncComponentState
+> extends Component<P, S> {
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  constructor(props: P, context: any) {
+    super(props, context);
+
+    this.api = new Client();
+    this.fetchData = wrapErrorHandling(this, this.fetchData.bind(this));
+    this.render = wrapErrorHandling(this, this.render.bind(this));
+
+    this.state = this.getDefaultState() as Readonly<S>;
+
+    this._measurement = {
+      hasMeasured: false,
+    };
+    if (props.routes) {
+      metric.mark({name: `async-component-${getRouteStringFromRoutes(props.routes)}`});
+    }
+  }
+
+  componentDidMount() {
+    this.fetchData();
+
+    if (this.reloadOnVisible) {
+      document.addEventListener('visibilitychange', this.visibilityReloader);
+    }
+  }
+
+  componentDidUpdate(prevProps: P, prevContext: any) {
+    const isRouterInContext = !!prevContext.router;
+    const isLocationInProps = prevProps.location !== undefined;
+
+    const currentLocation = isLocationInProps
+      ? this.props.location
+      : isRouterInContext
+      ? this.context.router.location
+      : null;
+    const prevLocation = isLocationInProps
+      ? prevProps.location
+      : isRouterInContext
+      ? prevContext.router.location
+      : null;
+
+    if (!(currentLocation && prevLocation)) {
+      return;
+    }
+
+    // Take a measurement from when this component is initially created until it finishes it's first
+    // set of API requests
+    if (
+      !this._measurement.hasMeasured &&
+      this._measurement.finished &&
+      this.props.routes
+    ) {
+      const routeString = getRouteStringFromRoutes(this.props.routes);
+      metric.measure({
+        name: 'app.component.async-component',
+        start: `async-component-${routeString}`,
+        data: {
+          route: routeString,
+          error: this._measurement.error,
+        },
+      });
+      this._measurement.hasMeasured = true;
+    }
+
+    // Re-fetch data when router params change.
+    if (
+      !isEqual(this.props.params, prevProps.params) ||
+      currentLocation.search !== prevLocation.search ||
+      currentLocation.state !== prevLocation.state
+    ) {
+      this.remountComponent();
+    }
+  }
+
+  componentWillUnmount() {
+    this.api.clear();
+    document.removeEventListener('visibilitychange', this.visibilityReloader);
+  }
+
+  /**
+   * Override this flag to have the component reload its state when the window
+   * becomes visible again. This will set the loading and reloading state, but
+   * will not render a loading state during reloading.
+   *
+   * eslint-disable-next-line react/sort-comp
+   */
+  reloadOnVisible = false;
+
+  /**
+   * When enabling reloadOnVisible, this flag may be used to turn on and off
+   * the reloading. This is useful if your component only needs to reload when
+   * becoming visible during certain states.
+   *
+   * eslint-disable-next-line react/sort-comp
+   */
+  shouldReloadOnVisible = false;
+
+  /**
+   * This affects how the component behaves when `remountComponent` is called
+   * By default, the component gets put back into a "loading" state when re-fetching data.
+   * If this is true, then when we fetch data, the original ready component remains mounted
+   * and it will need to handle any additional "reloading" states
+   */
+  shouldReload = false;
+
+  /**
+   * should `renderError` render the `detail` attribute of a 400 error
+   */
+  shouldRenderBadRequests = false;
+
+  /**
+   * If a request fails and is not a bad request, and if `disableErrorReport` is set to false,
+   * the UI will display an error modal.
+   *
+   * It is recommended to enable this property ideally only when the subclass is used by a top level route.
+   */
+  disableErrorReport = true;
+
+  api: Client = new Client();
+  private _measurement: any;
+
+  // XXX: can't call this getInitialState as React whines
+  getDefaultState(): AsyncComponentState {
+    const endpoints = this.getEndpoints();
+
+    const state = {
+      // has all data finished requesting?
+      loading: true,
+      // is the component reload
+      reloading: false,
+      // is there an error loading ANY data?
+      error: false,
+      errors: {},
+      // We will fetch immeditaely upon mount
+      remainingRequests: endpoints.length || undefined,
+    };
+
+    // We are not loading if there are no endpoints
+    if (!endpoints.length) {
+      state.loading = false;
+    }
+
+    endpoints.forEach(([stateKey, _endpoint]) => {
+      state[stateKey] = null;
+    });
+    return state;
+  }
+
+  // Check if we should measure render time for this component
+  markShouldMeasure = ({
+    remainingRequests,
+    error,
+  }: {error?: any; remainingRequests?: number} = {}) => {
+    if (!this._measurement.hasMeasured) {
+      this._measurement.finished = remainingRequests === 0;
+      this._measurement.error = error || this._measurement.error;
+    }
+  };
+
+  remountComponent = () => {
+    if (this.shouldReload) {
+      this.reloadData();
+    } else {
+      this.setState(this.getDefaultState(), this.fetchData);
+    }
+  };
+
+  visibilityReloader = () =>
+    this.shouldReloadOnVisible &&
+    !this.state.loading &&
+    !document.hidden &&
+    this.reloadData();
+
+  reloadData() {
+    this.fetchData({reloading: true});
+  }
+
+  fetchData = (extraState?: object) => {
+    const endpoints = this.getEndpoints();
+
+    if (!endpoints.length) {
+      this.setState({loading: false, error: false});
+      return;
+    }
+
+    // Cancel any in flight requests
+    this.api.clear();
+
+    this.setState({
+      loading: true,
+      error: false,
+      remainingRequests: endpoints.length,
+      ...extraState,
+    });
+
+    endpoints.forEach(([stateKey, endpoint, params, options]) => {
+      options = options || {};
+      // If you're using nested async components/views make sure to pass the
+      // props through so that the child component has access to props.location
+      const locationQuery = (this.props.location && this.props.location.query) || {};
+      let query = (params && params.query) || {};
+      // If paginate option then pass entire `query` object to API call
+      // It should only be expecting `query.cursor` for pagination
+      if ((options.paginate || locationQuery.cursor) && !options.disableEntireQuery) {
+        query = {...locationQuery, ...query};
+      }
+
+      this.api.request(endpoint, {
+        method: 'GET',
+        ...params,
+        query,
+        success: (data, _, resp) => {
+          this.handleRequestSuccess({stateKey, data, resp}, true);
+        },
+        error: error => {
+          // Allow endpoints to fail
+          // allowError can have side effects to handle the error
+          if (options.allowError && options.allowError(error)) {
+            error = null;
+          }
+          this.handleError(error, [stateKey, endpoint, params, options]);
+        },
+      });
+    });
+  };
+
+  onRequestSuccess(_resp /* {stateKey, data, resp} */) {
+    // Allow children to implement this
+  }
+
+  onRequestError(_resp, _args) {
+    // Allow children to implement this
+  }
+
+  onLoadAllEndpointsSuccess() {
+    // Allow children to implement this
+  }
+
+  handleRequestSuccess({stateKey, data, resp}, initialRequest?: boolean) {
+    this.setState(
+      prevState => {
+        const state = {
+          [stateKey]: data,
+          // TODO(billy): This currently fails if this request is retried by SudoModal
+          [`${stateKey}PageLinks`]: resp?.getResponseHeader('Link'),
+        };
+
+        if (initialRequest) {
+          state.remainingRequests = prevState.remainingRequests! - 1;
+          state.loading = prevState.remainingRequests! > 1;
+          state.reloading = prevState.reloading && state.loading;
+          this.markShouldMeasure({remainingRequests: state.remainingRequests});
+        }
+
+        return state;
+      },
+      () => {
+        // if everything is loaded and we don't have an error, call the callback
+        if (this.state.remainingRequests === 0 && !this.state.error) {
+          this.onLoadAllEndpointsSuccess();
+        }
+      }
+    );
+    this.onRequestSuccess({stateKey, data, resp});
+  }
+
+  handleError(error, args) {
+    const [stateKey] = args;
+    if (error && error.responseText) {
+      Sentry.addBreadcrumb({
+        message: error.responseText,
+        category: 'xhr',
+        level: 'error',
+      });
+    }
+    this.setState(prevState => {
+      const loading = prevState.remainingRequests! > 1;
+      const state: AsyncComponentState = {
+        [stateKey]: null,
+        errors: {
+          ...prevState.errors,
+          [stateKey]: error,
+        },
+        error: prevState.error || !!error,
+        remainingRequests: prevState.remainingRequests! - 1,
+        loading,
+        reloading: prevState.reloading && loading,
+      };
+      this.markShouldMeasure({remainingRequests: state.remainingRequests, error: true});
+
+      return state;
+    });
+    this.onRequestError(error, args);
+  }
+
+  /**
+   * Return a list of endpoint queries to make.
+   *
+   * return [
+   *   ['stateKeyName', '/endpoint/', {optional: 'query params'}, {options}]
+   * ]
+   */
+  getEndpoints(): Array<[string, string, any?, any?]> {
+    return [];
+  }
+
+  renderSearchInput({stateKey, url, ...props}: RenderSearchInputArgs) {
+    const [firstEndpoint] = this.getEndpoints() || [null];
+    const stateKeyOrDefault = stateKey || (firstEndpoint && firstEndpoint[0]);
+    const urlOrDefault = url || (firstEndpoint && firstEndpoint[1]);
+    return (
+      <AsyncComponentSearchInput
+        url={urlOrDefault}
+        {...props}
+        api={this.api}
+        onSuccess={(data, resp) => {
+          this.handleRequestSuccess({stateKey: stateKeyOrDefault, data, resp});
+        }}
+        onError={() => {
+          this.renderError(new Error('Error with AsyncComponentSearchInput'));
+        }}
+      />
+    );
+  }
+
+  renderLoading(): React.ReactNode {
+    return <LoadingIndicator />;
+  }
+
+  renderError(error?: Error, disableLog = false): React.ReactNode {
+    const {errors} = this.state;
+
+    // 401s are captured by SudoModal, but may be passed back to AsyncComponent if they close the modal without identifying
+    const unauthorizedErrors = Object.values(errors).find(resp => resp?.status === 401);
+
+    // Look through endpoint results to see if we had any 403s, means their role can not access resource
+    const permissionErrors = Object.values(errors).find(resp => resp?.status === 403);
+
+    // If all error responses have status code === 0, then show error message but don't
+    // log it to sentry
+    const shouldLogSentry =
+      !!Object.values(errors).find(resp => resp?.status !== 0) || disableLog;
+
+    if (unauthorizedErrors) {
+      return (
+        <LoadingError message={t('You are not authorized to access this resource.')} />
+      );
+    }
+
+    if (permissionErrors) {
+      return <PermissionDenied />;
+    }
+
+    if (this.shouldRenderBadRequests) {
+      const badRequests = Object.values(errors)
+        .filter(resp => resp?.status === 400 && resp?.responseJSON?.detail)
+        .map(resp => resp.responseJSON.detail);
+
+      if (badRequests.length) {
+        return <LoadingError message={[...new Set(badRequests)].join('\n')} />;
+      }
+    }
+
+    return (
+      <RouteError
+        error={error}
+        disableLogSentry={!shouldLogSentry}
+        disableReport={this.disableErrorReport}
+      />
+    );
+  }
+
+  get shouldRenderLoading() {
+    return this.state.loading && (!this.shouldReload || !this.state.reloading);
+  }
+
+  renderComponent() {
+    return this.shouldRenderLoading
+      ? this.renderLoading()
+      : this.state.error
+      ? this.renderError()
+      : this.renderBody();
+  }
+
+  /**
+   * Renders once all endpoints have been loaded
+   */
+  renderBody(): React.ReactNode {
+    // Allow children to implement this
+    throw new Error('Not implemented');
+  }
+
+  render() {
+    return this.renderComponent();
+  }
+}
+
+export default DeprecatedAsyncComponent;

+ 4 - 4
static/app/components/events/attachmentViewers/jsonViewer.tsx

@@ -1,18 +1,18 @@
 import styled from '@emotion/styled';
 
-import AsyncComponent from 'sentry/components/asyncComponent';
 import ContextData from 'sentry/components/contextData';
+import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 import PreviewPanelItem from 'sentry/components/events/attachmentViewers/previewPanelItem';
 import {
   getAttachmentUrl,
   ViewerProps,
 } from 'sentry/components/events/attachmentViewers/utils';
 
-type Props = ViewerProps & AsyncComponent['props'];
+type Props = ViewerProps & DeprecatedAsyncComponent['props'];
 
-type State = AsyncComponent['state'];
+type State = DeprecatedAsyncComponent['state'];
 
-export default class JsonViewer extends AsyncComponent<Props, State> {
+export default class JsonViewer extends DeprecatedAsyncComponent<Props, State> {
   getEndpoints(): [string, string][] {
     return [['attachmentJson', getAttachmentUrl(this.props)]];
   }

+ 4 - 4
static/app/components/events/attachmentViewers/logFileViewer.tsx

@@ -1,7 +1,7 @@
 import styled from '@emotion/styled';
 import Ansi from 'ansi-to-react';
 
-import AsyncComponent from 'sentry/components/asyncComponent';
+import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 import PreviewPanelItem from 'sentry/components/events/attachmentViewers/previewPanelItem';
 import {
   getAttachmentUrl,
@@ -9,11 +9,11 @@ import {
 } from 'sentry/components/events/attachmentViewers/utils';
 import {space} from 'sentry/styles/space';
 
-type Props = ViewerProps & AsyncComponent['props'];
+type Props = ViewerProps & DeprecatedAsyncComponent['props'];
 
-type State = AsyncComponent['state'];
+type State = DeprecatedAsyncComponent['state'];
 
-class LogFileViewer extends AsyncComponent<Props, State> {
+class LogFileViewer extends DeprecatedAsyncComponent<Props, State> {
   getEndpoints(): [string, string][] {
     return [['attachmentText', getAttachmentUrl(this.props)]];
   }

+ 5 - 5
static/app/components/events/groupingInfo/groupingConfigSelect.tsx

@@ -1,6 +1,6 @@
 import styled from '@emotion/styled';
 
-import AsyncComponent from 'sentry/components/asyncComponent';
+import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
 import DropdownButton from 'sentry/components/dropdownButton';
 import {Tooltip} from 'sentry/components/tooltip';
@@ -9,17 +9,17 @@ import {EventGroupingConfig} from 'sentry/types';
 
 import {GroupingConfigItem} from '.';
 
-type Props = AsyncComponent['props'] & {
+type Props = DeprecatedAsyncComponent['props'] & {
   configId: string;
   eventConfigId: string;
   onSelect: (selection: any) => void;
 };
 
-type State = AsyncComponent['state'] & {
+type State = DeprecatedAsyncComponent['state'] & {
   configs: EventGroupingConfig[];
 };
 
-class GroupingConfigSelect extends AsyncComponent<Props, State> {
+class GroupingConfigSelect extends DeprecatedAsyncComponent<Props, State> {
   getDefaultState() {
     return {
       ...super.getDefaultState(),
@@ -27,7 +27,7 @@ class GroupingConfigSelect extends AsyncComponent<Props, State> {
     };
   }
 
-  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+  getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
     return [['configs', '/grouping-configs/']];
   }
 

+ 5 - 5
static/app/components/events/groupingInfo/index.tsx

@@ -1,8 +1,8 @@
 import {Fragment} from 'react';
 import styled from '@emotion/styled';
 
-import AsyncComponent from 'sentry/components/asyncComponent';
 import {Button} from 'sentry/components/button';
+import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 import {EventDataSection} from 'sentry/components/events/eventDataSection';
 import {FeatureFeedback} from 'sentry/components/featureFeedback';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
@@ -16,7 +16,7 @@ import {groupingFeedbackTypes} from 'sentry/views/issueDetails/grouping/grouping
 import GroupingConfigSelect from './groupingConfigSelect';
 import GroupVariant from './groupingVariant';
 
-type Props = AsyncComponent['props'] & {
+type Props = DeprecatedAsyncComponent['props'] & {
   event: Event;
   organization: Organization;
   projectSlug: string;
@@ -24,14 +24,14 @@ type Props = AsyncComponent['props'] & {
   group?: Group;
 };
 
-type State = AsyncComponent['state'] & {
+type State = DeprecatedAsyncComponent['state'] & {
   configOverride: string | null;
   groupInfo: EventGroupInfo;
   isOpen: boolean;
 };
 
-class GroupingInfo extends AsyncComponent<Props, State> {
-  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+class GroupingInfo extends DeprecatedAsyncComponent<Props, State> {
+  getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
     const {organization, event, projectSlug, group} = this.props;
 
     if (

+ 6 - 6
static/app/components/events/interfaces/debugMeta/debugImageDetails/index.tsx

@@ -6,9 +6,9 @@ import sortBy from 'lodash/sortBy';
 
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
 import {ModalRenderProps} from 'sentry/actionCreators/modal';
-import AsyncComponent from 'sentry/components/asyncComponent';
 import {Button} from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
+import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {Organization, Project} from 'sentry/types';
@@ -28,7 +28,7 @@ import {INTERNAL_SOURCE, INTERNAL_SOURCE_LOCATION} from './utils';
 
 type ImageCandidates = Image['candidates'];
 
-type Props = AsyncComponent['props'] &
+type Props = DeprecatedAsyncComponent['props'] &
   ModalRenderProps & {
     event: Event;
     organization: Organization;
@@ -37,11 +37,11 @@ type Props = AsyncComponent['props'] &
     onReprocessEvent?: () => void;
   };
 
-type State = AsyncComponent['state'] & {
+type State = DeprecatedAsyncComponent['state'] & {
   debugFiles: Array<DebugFile> | null;
 };
 
-export class DebugImageDetails extends AsyncComponent<Props, State> {
+export class DebugImageDetails extends DeprecatedAsyncComponent<Props, State> {
   getDefaultState(): State {
     return {
       ...super.getDefaultState(),
@@ -61,7 +61,7 @@ export class DebugImageDetails extends AsyncComponent<Props, State> {
     return candidates.find(candidate => candidate.source === INTERNAL_SOURCE);
   }
 
-  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+  getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
     const {organization, projSlug, image} = this.props;
 
     if (!image) {
@@ -71,7 +71,7 @@ export class DebugImageDetails extends AsyncComponent<Props, State> {
     const {debug_id, candidates = []} = image;
 
     const hasUploadedDebugFiles = this.getUploadedDebugFiles(candidates);
-    const endpoints: ReturnType<AsyncComponent['getEndpoints']> = [];
+    const endpoints: ReturnType<DeprecatedAsyncComponent['getEndpoints']> = [];
 
     if (hasUploadedDebugFiles) {
       endpoints.push([

Some files were not shown because too many files changed in this diff