Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix es_ui_shared eslint violations for useRequest hook #72947

Merged
merged 26 commits into from
Aug 31, 2020

Conversation

cjcenizal
Copy link
Contributor

@cjcenizal cjcenizal commented Jul 22, 2020

Replaces #59626


Partially addresses #49554

Closes #49572 (originally fixed as part of the NP migration)
Closes #49562 (originally fixed as part of the NP migration)
Closes #42225 (flaky test)
Closes #42563 (flaky test)
Closes #42562 (flaky test)
Closes #42561 (flaky test)

This PR:

  • Removes the unused original request.ts helper and replaces it with the NP-ready version
  • Adds clean-up logic for when the component using this helper is unmounted (This was reverted)
  • Unskips the tests and rewrites them to run synchronously using fake timers.
  • Converts the tests to TypeScript.
  • Relaxes the SendRequestResponse type definition to type error as any instead of Error, since we weren't actually fulfilling that type.

To test:

  • Verify that requests behave as expected in Index Management, Watcher, Snapshot Restore, Ingest Manager, and Transforms

Errors

I tested error-handling using data streams.

ES errors

405

image

400

image

caused_by chain

I was concerned that the change in the way sendRequest surfaced errors would change the way caused_by chains were being surfaced in the UI, but it looks like the two apps that use this module and surface caused_by chains actually return them as part of the success response, not the error response. These apps are Snapshot Restore (deletRepositories endpoint) and Index Management (deleteDataStreams and deleteIndexTemplates endpoints).

image
image

Non-ES errors

500

image

500

image

@cjcenizal cjcenizal added chore v8.0.0 Team:Kibana Management Dev Tools, Index Management, Upgrade Assistant, ILM, Ingest Node Pipelines, and more release_note:skip Skip the PR/issue when compiling release notes v7.10.0 labels Jul 22, 2020
@cjcenizal cjcenizal force-pushed the chore/use-request-eslint branch 6 times, most recently from 2897789 to 2554f46 Compare July 31, 2020 16:54
@cjcenizal cjcenizal marked this pull request as ready for review July 31, 2020 16:54
@cjcenizal cjcenizal requested a review from a team as a code owner July 31, 2020 16:54
@elasticmachine
Copy link
Contributor

Pinging @elastic/es-ui (Team:Elasticsearch UI)

@cjcenizal cjcenizal requested a review from sebelga July 31, 2020 16:55
- Add clearer cleanup logic for unmounted components.
- Align logic and comments in np_ready_request.ts and original request.ts.
…o false during cleanups unrelated to unmounting.
- Split request.ts into send_request.ts and use_request.ts.
- Convert test files into TS.
- Relax SendRequestResponse type definition to type error as any instead of expecting an Error, since we weren't actually fulfilling this expectation.
@cjcenizal cjcenizal force-pushed the chore/use-request-eslint branch from 2554f46 to 961b0b2 Compare August 1, 2020 16:19
@cjcenizal cjcenizal force-pushed the chore/use-request-eslint branch from 961b0b2 to 2fe8e19 Compare August 1, 2020 16:34
// To be functionally correct we'd send a new request if the method, path, query or body changes.
// But it doesn't seem likely that the method will change and body is likely to be a new
// object even if its shape hasn't changed, so for now we're just watching the path and the query.
}, [path, stringifiedQuery]);
}, [path, query]);
Copy link
Contributor Author

@cjcenizal cjcenizal Aug 1, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the only dependencies that I think should have any effect on the hook behavior, at least at this point in this hook's development. If we were to change the refs above to useCallback, I'm concerned about introducing new and possibly unwanted behavior. For example, converting sendRequestRef to useCallback means defining dependencies that duplicate those defined here (see below). I think this makes the code's behavior more difficult to predict from reading it and I'm not clear on what the benefit would be.

I'm open to exploring this in separate PRs, but I think for the context of this PR I'd like to avoid introducing any complexity that doesn't have a clear, deliberate purpose (e.g. fixing a bug or defining behavior more clearly). I think the current code makes it clear what will happen if a) dependencies change and b) sendRequestRef is called. If other people feel the same way then I think that's enough for us to consider this merge-worthy in its current form.

  const sendRequest = useCallback(async () => {
    // We don't clear error or data, so it's up to the consumer to decide whether to display the
    // "old" error/data or loading state when a new request is in-flight.
    setIsLoading(true);

    const requestBody = {
      path,
      method,
      query,
      body,
    };

    const response = await sendRequest<D, E>(httpClient, requestBody);
    const { data: serializedResponseData, error: responseError } = response;

    // If an outdated request has resolved, ignore its outdated data.
    if (isOutdatedRequest) {
      return { data: null, error: null };
    }

    setError(responseError);
    // If there's an error, keep the data from the last request in case it's still useful to the user.
    if (!responseError) {
      const responseData = deserializer(serializedResponseData);
      setData(responseData);
    }
    setIsLoading(false);
    setIsInitialRequest(false);

    // If we're on an interval, we need to schedule the next request. This also allows us to reset
    // the interval if the user has manually requested the data, to avoid doubled-up requests.
    scheduleRequestRef.current!();

    return { data: serializedResponseData, error: responseError };
  }, [body, deserializer, httpClient, isOutdatedRequest, method, path, query]);

@cjcenizal
Copy link
Contributor Author

cjcenizal commented Aug 3, 2020

CC @walterra @jen-huang. The code here is the same as what you approved in #59626. I'm tagging you for visibility, and I intend to merge this once @sebelga approves. We have most of the release cycle to detect any bugs introduced into the consuming apps by these changes.

One subtle change that could cause a change in behavior is in the way sendRequest returns errors. It changed from this:

return {
  data: null,
  error: e.response ? e.response : e,
};

To this:

return {
  data: null,
  error: e.response && e.response.data ? e.response.data : e.body,
};

To find and fix any bugs means we'll need to look through Snapshot Restore and Ingest Manager, which depended upon the old behavior.

@cjcenizal
Copy link
Contributor Author

@sebelga Thanks for catching that bug! I accidentally introduced that in 2fe8e19, so I reverted the commit and added a comment to explain that line's role.

…ed on its dependencies.

- Update sendRequest to clear the poll interval immediately when it's called.
- Update eslintrc to lint es_ui_shared.
- Type arguments correctly as UseRequestConfig in helpers.
if (!isLoading) {
scheduleRequest();
}
}, [isLoading, scheduleRequest]);
Copy link
Contributor Author

@cjcenizal cjcenizal Aug 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that here we're using isLoading to signal the need to reschedule a request, and not totalRequests as in https://github.com/cjcenizal/kibana/pull/25/files#diff-a60f7c6c34d3d037970d34856d1b3da2R142.

I think it makes sense to keep each hook as single-purpose as possible. This hook's sole purpose is to manage the polling rhythm.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is a little bit less explicit than with my totalRequest count (I personally understand better "when the request count increments, reschedule a request", as "isLoading" seems like it could change independently.) but I am OK to go with your suggestion.

But your comment is not right, the reschedule will happen whenever scheduleRequest changes, thus whenever its dependencies change.

Also, you are missing a

if (!isMounted.current) {
  return;
}

on top, as we don't want to schedule a request when the component mounts, but when the initial request finished.

Copy link
Contributor Author

@cjcenizal cjcenizal Aug 31, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But your comment is not right, the reschedule will happen whenever scheduleRequest changes, thus whenever its dependencies change.

Could you explain this a bit? From the way I read this code, it looks like the effect is called whenever scheduleRequest (and therefore sendRequest) changes, but the isLoading condition prevents scheduleRequest from being called each time.

we don't want to schedule a request when the component mounts, but when the initial request finished.

isLoading is true when the component mounts, so the request isn't being initially scheduled. I'll add a comment to clarify this.

Copy link
Contributor Author

@cjcenizal cjcenizal Aug 31, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For context, I had to switch away from using totalRequests because here we're incrementing it before the request completes to get the request ID, instead of after like you originally suggested. This meant that if we used it to schedule the next request, then the next request would be scheduled before the first one completes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isLoading is true when the component mounts

This is the problem I think. It should be false (as I was expecting it) and it is set to true when sendRequest() is called.

Could you explain this a bit?

Nevermind I will understand it. For me adding so much comment makes it harder to follow and it seems that there is something "special" the code, but that's ok.
I was saying that the ´useEffect´ will be triggered whenever any of the deps changes (which is simply how useEffect works). And of course, the if condition prevents the reschedule. But I don't need a comment to explain that.

My mental model was

const [isLoading, setIsLoading] = useState(false); // nothing happened just yet

...

useEffect(() => {
  if (!isMounted.current) {
    return; // clearly indicate to not schedule anything on mount
  }

  if (!isLoading) {
    scheduleRequest();
  }

}, [isLoading, scheduleRequest]);

With the above code, I don't need any comment as it is self explanatory.

For context, I had to switch away from using totalRequests because here we're incrementing it before the request completes to get the request ID, instead of after like you originally suggested. This meant that if we used it to schedule the next request, then the next request would be scheduled before the first one completes.

I am not following but that's OK I guess 😄

@cjcenizal
Copy link
Contributor Author

@sebelga I've converted all of sendRequest's internals to hooks, with a couple key differences from your original suggestion. I added some extra tests to define the behavior I think we want. Can you please take another look?

@cjcenizal
Copy link
Contributor Author

@elasticmachine merge upstream

// Setting isLoading to false also acts as a signal for scheduling the next poll request.
setIsLoading(false);

return { data: serializedResponseData, error: responseError };
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's probably an antipattern that we're returning values from sendRequest. Seems like the consumer should be depending on the values returned by the hook instead. Once we merge this I'll create an issue to check for any consumers that depend on this return value and refactor them so we can try to remove this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 This is something I noticed since the first iteration. But for some reason, we added the "// Gives the user the ability to manually request data" comment and exposed the handler.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we do want to retain sendRequest as part of the useRequest hook's return value, because we need it for "Reload" button behavior. But I don't think calling sendRequest should return a promise that resolves to data and error, since that information is already available off the useRequest hook's return value.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok I see, then maybe the API should be clearer and expose a refresh() handler that indeed does not return data nor error.

Copy link
Contributor

@sebelga sebelga left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome work @cjcenizal ! We almost got it! 😊 I left some not about unnecessary comments that "restate trivial things". For the hook I left a comment about a missing mount check that should be added. I will approve but it'd be good to add it before merging. Thanks!

// Setting isLoading to false also acts as a signal for scheduling the next poll request.
setIsLoading(false);

return { data: serializedResponseData, error: responseError };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 This is something I noticed since the first iteration. But for some reason, we added the "// Gives the user the ability to manually request data" comment and exposed the handler.

clearPollInterval();

// Pull off request ID and increment request count.
const requestId = requestCountRef.current++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: here it would be better to have

const requestId = ++requestCountRef.current;

and not have the requestCountRef.current - 1; below (the -1 part)

// If there's a scheduled poll request, this new one will supersede it.
clearPollInterval();

// Schedule next poll request.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: same here about unnecessary comment

}
}, [pollIntervalMs, sendRequest, clearPollInterval]);

// Send the request on component mount and whenever the dependencies of sendRequest() change.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: same here about the unnecessary comment (here you are basically describing the useEffect lifecycle.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the other comments you pointed out. I think this is the one comment I'm going to keep, just to really emphasize "the dependencies of sendRequest()" bit. It seems super obvious after staring at this code for hours, but I think the comment is more for someone who hasn't already spent a ton of time looking at the code. :)

if (!isLoading) {
scheduleRequest();
}
}, [isLoading, scheduleRequest]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is a little bit less explicit than with my totalRequest count (I personally understand better "when the request count increments, reschedule a request", as "isLoading" seems like it could change independently.) but I am OK to go with your suggestion.

But your comment is not right, the reschedule will happen whenever scheduleRequest changes, thus whenever its dependencies change.

Also, you are missing a

if (!isMounted.current) {
  return;
}

on top, as we don't want to schedule a request when the component mounts, but when the initial request finished.

// data, to avoid doubled-up requests.
clearPollInterval();

// Pull off request ID and increment request count.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I am not sure it is necessary to comment on obvious code behavior 😊 As per our guidelines "Don't use comments to restate trivial things." (https://github.com/elastic/kibana/blob/master/STYLEGUIDE.md#use-slashes-for-comments)

@@ -94,12 +94,6 @@ module.exports = {
'jsx-a11y/no-onchange': 'off',
},
},
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! Thanks for removing it here and adding the comments in the few places we still have the issue 👍

@cjcenizal cjcenizal merged commit e13d8dc into elastic:master Aug 31, 2020
cjcenizal added a commit that referenced this pull request Aug 31, 2020
* Reconcile request helpers with eslint rules for React hooks.
  - Add clearer cleanup logic for unmounted components.
  - Align logic and comments in np_ready_request.ts and original request.ts.
* Reorganize modules and convert tests to TS.
  - Split request.ts into send_request.ts and use_request.ts.
  - Convert test files into TS.
  - Relax SendRequestResponse type definition to type error as any instead of expecting an Error, since we weren't actually fulfilling this expectation.
* Convert everything to hooks and add test coverage for request behavior.
* Fix Watcher memoization bugs.
# Conflicts:
#	.eslintrc.js
@cjcenizal
Copy link
Contributor Author

@elastic/ingest-management @elastic/ml-ui Sorry for not pinging you before merging this! Could you please test out your apps which consume useRequest and verify that they still function correctly? I'd look extra carefully at your error-handling. If you specify the body, query, deserializer, or pollIntervalMs arguments, I'd also test that logic in particular.

@kibanamachine
Copy link
Contributor

💚 Build Succeeded

Build metrics

@kbn/optimizer bundle module count

id value diff baseline
esUiShared 229 +2 227

async chunks size

id value diff baseline
watcher 602.5KB +602.0B 602.0KB

page load bundle size

id value diff baseline
esUiShared 991.0KB +462.0B 990.6KB
indexManagement 248.1KB -8.0B 248.1KB
total +454.0B

History

To update your PR or re-run it, just comment with:
@elasticmachine merge upstream

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment