PLG: use react-query for async state management#62939
Conversation
…cegraph/sourcegraph into ty/plg-subscription-management-page
| const isLoading = isStripeLoading || updateCurrentSubscriptionMutation.isPending | ||
| const errorMessage = | ||
| stripeErrorMessage || updateCurrentSubscriptionMutation.isError ? updateSubscriptionMutationErrorText : '' |
There was a problem hiding this comment.
The form submit handler has two types of async operations: loading and validating Stripe elements and making an SSC request.
We handle error and loading states for these two types separately and then use a derived values (isLoading, errorMessage) to define the component return value.
This logic is repeated in BillingAddressForm component and we may want to extract the shared part in future, but IMO it's out of scope of this PR.
| <Button | ||
| variant="primary" | ||
| disabled={updateCurrentSubscriptionMutation.isPending} | ||
| className={styles.iconButton} | ||
| onClick={() => | ||
| updateCurrentSubscriptionMutation.mutate({ | ||
| subscriptionUpdate: { newCancelAtPeriodEnd: false }, | ||
| }) | ||
| } | ||
| > | ||
| {updateCurrentSubscriptionMutation.isPending ? ( | ||
| <LoadingSpinner className="mr-1" /> | ||
| ) : ( | ||
| <Icon aria-hidden={true} className="mr-1" svgPath={mdiRefresh} /> | ||
| )} | ||
| Renew subscription | ||
| </Button> | ||
| ) : ( | ||
| <CancelSubscriptionButton | ||
| isLoading={isLoading} | ||
| currentPeriodEnd={props.subscription.currentPeriodEnd} | ||
| onClick={() => updateSubscription('cancel')} | ||
| /> | ||
| <> | ||
| <Button variant="secondary" onClick={() => setIsConfirmationModalVisible(true)}> | ||
| <Icon aria-hidden={true} svgPath={mdiCancel} className="mr-1" /> | ||
| Cancel subscription | ||
| </Button> | ||
|
|
||
| {isConfirmationModalVisible && ( | ||
| <Modal | ||
| aria-label="Confirmation modal" | ||
| onDismiss={() => setIsConfirmationModalVisible(false)} | ||
| > | ||
| <div className="pb-3"> | ||
| <H3>Are you sure?</H3> | ||
| <Text className="mt-4"> | ||
| Canceling you subscription now means that you won't be able to use Cody with Pro | ||
| features after {humanizeDate(props.subscription.currentPeriodEnd)}. | ||
| </Text> | ||
| <Text className={classNames('mt-4 mb-0', styles.bold)}> | ||
| Do you want to procceed? | ||
| </Text> | ||
| </div> | ||
| <div className={classNames('d-flex mt-4', styles.buttonContainer)}> | ||
| <Button | ||
| variant="secondary" | ||
| outline={true} | ||
| onClick={() => setIsConfirmationModalVisible(false)} | ||
| > | ||
| No, I've changed my mind | ||
| </Button> | ||
| <Button | ||
| variant="primary" | ||
| disabled={updateCurrentSubscriptionMutation.isPending} | ||
| className={styles.iconButton} | ||
| onClick={() => | ||
| updateCurrentSubscriptionMutation.mutate( | ||
| { subscriptionUpdate: { newCancelAtPeriodEnd: true } }, | ||
| { onSettled: () => setIsConfirmationModalVisible(false) } | ||
| ) | ||
| } | ||
| > | ||
| {updateCurrentSubscriptionMutation.isPending ? ( | ||
| <LoadingSpinner className="mr-1" /> | ||
| ) : ( | ||
| <Icon aria-hidden={true} className="mr-1" svgPath={mdiCheck} /> | ||
| )} | ||
| <Text as="span">Yes, cancel</Text> | ||
| </Button> | ||
| </div> | ||
| </Modal> | ||
| )} | ||
| </> | ||
| )} | ||
| </div> | ||
|
|
||
| {errorMessage && <Text className="mt-3 text-danger">{errorMessage}</Text>} | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
| const RenewSubscriptionButton: React.FC<{ | ||
| isLoading: boolean | ||
| onClick: () => Promise<void> | ||
| }> = props => ( | ||
| <Button variant="primary" disabled={props.isLoading} className={styles.iconButton} onClick={props.onClick}> | ||
| {props.isLoading ? ( | ||
| <LoadingSpinner className="mr-1" /> | ||
| ) : ( | ||
| <Icon aria-hidden={true} className="mr-1" svgPath={mdiRefresh} /> | ||
| )} | ||
| Renew subscription | ||
| </Button> | ||
| ) | ||
|
|
||
| const CancelSubscriptionButton: React.FC<{ | ||
| isLoading: boolean | ||
| currentPeriodEnd: Subscription['currentPeriodEnd'] | ||
| onClick: () => Promise<void> | ||
| }> = props => { | ||
| const [isConfirmationModalVisible, setIsConfirmationModalVisible] = useState(false) | ||
|
|
||
| return ( | ||
| <> | ||
| <Button variant="secondary" onClick={() => setIsConfirmationModalVisible(true)}> | ||
| <Icon aria-hidden={true} svgPath={mdiCancel} className="mr-1" /> | ||
| Cancel subscription | ||
| </Button> | ||
|
|
||
| {isConfirmationModalVisible && ( | ||
| <Modal aria-label="Confirmation modal" onDismiss={() => setIsConfirmationModalVisible(false)}> | ||
| <div className="pb-3"> | ||
| <H3>Are you sure?</H3> | ||
| <Text className="mt-4"> | ||
| Canceling you subscription now means that you won't be able to use Cody with Pro features | ||
| after {humanizeDate(props.currentPeriodEnd)}. | ||
| </Text> | ||
| <Text className={classNames('mt-4 mb-0', styles.bold)}>Do you want to procceed?</Text> | ||
| </div> | ||
| <div className={classNames('d-flex mt-4', styles.buttonContainer)}> | ||
| <Button variant="secondary" outline={true} onClick={() => setIsConfirmationModalVisible(false)}> | ||
| No, I've changed my mind | ||
| </Button> | ||
| <Button | ||
| variant="primary" | ||
| disabled={props.isLoading} | ||
| className={styles.iconButton} | ||
| onClick={() => props.onClick().finally(() => setIsConfirmationModalVisible(false))} | ||
| > | ||
| {props.isLoading ? ( | ||
| <LoadingSpinner className="mr-1" /> | ||
| ) : ( | ||
| <Icon aria-hidden={true} className="mr-1" svgPath={mdiCheck} /> | ||
| )} | ||
| <Text as="span">Yes, cancel</Text> | ||
| </Button> | ||
| </div> | ||
| </Modal> | ||
| )} |
There was a problem hiding this comment.
I moved the logic for the RenewSubscriptionButton and CancelSubscriptionButton components back to SubscriptionDetails. The error message is rendered within SubscriptionDetails, and passing the mutation as a prop to the button components made the logic less readable. The readability of CancelSubscriptionButton would be even worse, as it would need to pass an onSettled callback to close the modal after the mutation completes.
IMO, this change is out of the scope of this PR. This is more of an FYI comment explaining why this kind of change is in a PR advertising the third-party state management library.
vdavid
left a comment
There was a problem hiding this comment.
@taras-yemets, I like the proposed solution, I think it's nice and clean. The article you linked is also very clear and convincing, and their docs seem clean and helpful. The repo is popular and active. So yeah, I'm absolutely for using this. 👍
| @@ -1,14 +1,13 @@ | |||
| import { useEffect, useState, useContext, useCallback } from 'react' | |||
| import { useEffect, useState, useContext } from 'react' | |||
There was a problem hiding this comment.
Ignore changes to useApiClient.tsx from this PR. It's just reverted to the main rev.
| class CodyProApiAuthError extends Error { | ||
| constructor(m: string) { | ||
| super(m) | ||
|
|
||
| Object.setPrototypeOf(this, CodyProApiAuthError.prototype) | ||
| } | ||
| } | ||
|
|
||
| const isCodyProApiAuthError = (err?: Error): err is CodyProApiAuthError => err instanceof CodyProApiAuthError | ||
|
|
||
| /** | ||
| * Returns an error message based on the type of error encountered. | ||
| * | ||
| * If the error is a specific Cody Pro API error (e.g., `CodyProApiAuthError` as result of expired SAMS credentials), | ||
| * it returns the error message associated with that error. Otherwise, it returns a custom caller-provided message. | ||
| * | ||
| * @param err - The error object encountered, if any. | ||
| * @param message - The custom message to return if the error is not a specific Cody Pro API authentication error. | ||
| * @returns The error message to display. | ||
| */ | ||
| export const getCodyProApiErrorMessage = (err: Error | null, message: string): string => { | ||
| if (!err) { | ||
| return '' | ||
| } | ||
|
|
||
| if (isCodyProApiAuthError(err)) { | ||
| return err.message | ||
| } | ||
|
|
||
| return message | ||
| } | ||
|
|
||
| // Wrapper around `apiCaller.call` which throws an error in case of a non-2xx response. | ||
| // Motivation taken from here: https://tanstack.com/query/latest/docs/framework/react/guides/query-functions#usage-with-fetch-and-other-clients-that-do-not-throw-by-default | ||
| const callCodyProApi = async <Data>(call: Call<Data>): Promise<{ data?: Data; response: Response }> => { | ||
| const result = await apiCaller.call(call) | ||
|
|
||
| if (!result.response.ok) { | ||
| if (result.response.status === 401) { | ||
| throw new CodyProApiAuthError('Unauthorized. Please log out and log back in.') | ||
| } | ||
| throw new Error(`Cody Pro API call failed with status ${result.response.status}`) | ||
| } | ||
|
|
||
| return result | ||
| } |
There was a problem hiding this comment.
Custom query hooks (e.g., useCurrentSubscription) and mutation hooks (e.g., useUpdateCurrentSubscription) may need to show custom error messages depending on the context. For example, both the card number form and the cancel subscription button use the useUpdateCurrentSubscription hook, but they may need to display different error messages in case of an error (e.g., "Failed to save credit card" for the former and "Failed to change subscription status" for the latter). Therefore, I leave it to the callers to define the error message.
However, there is a specific case when a request to the Cody Pro backend results in a 401 error due to expired SAMS credentials (we may have more such "special" cases in the future). In this situation, regardless of the caller context, we want to show a generic message such as "You are not authorized. Sign out and sign back in." To handle this, we need callers to distinguish errors returned from the query/mutation. For "special" cases (as identified by the getCodyProApiErrorMessage method), the specific error message should be shown. For any other errors, the custom message should depend on the caller's context.
I am not entirely satisfied with the current approach, where callers must call getCodyProApiErrorMessage to get the message text. However, I haven't found a better solution. Do you have better ideas, @vdavid, @chrsmith?
There was a problem hiding this comment.
I'd probably get rid of getCodyProApiErrorMessage and just do
isCodyProApiAuthError(err) ? err.message : 'Custom error message'whenever I want this logic.
If you don't think that's the right call here, then I suggest renaming the message parameter to defaultMessage, to better communicate its use, and maybe also mention it in getCodyProApiErrorMessage's doc that we intend to make this function more generic once we have more special cases.
There was a problem hiding this comment.
Thank you, @vdavid!
isCodyProApiAuthError(err) ? err.message : 'Custom error message'
With this approach to show custom (component-specific) errors in components, we need to throw empty errors in react-query hooks, and it doesn't feel right.
But the defaultErrorMessage approach would work, I think.
There was a problem hiding this comment.
With this approach to show custom (component-specific) errors in components, we need to throw empty errors in react-query hook, and it doesn't feel right.
Yeah, that definitely doesn't. But if you change this:
const errorMessage = getCodyProApiErrorMessage(subscriptionQueryResult.error, 'Failed to fetch subscription data')
if (errorMessage) {
return <Alert variant="danger">{errorMessage}</Alert>
}to
if (subscriptionQueryResult.error) {
return <Alert variant="danger">{isCodyProApiAuthError(err) ? err.message : 'Failed to fetch subscription data'}</Alert>
}that works, right?
There was a problem hiding this comment.
Ah, now I see. Thank you!
There was a problem hiding this comment.
I am 100,000% in support of adding more formality / types to describe the errors we receive. But let me suggest something slightly different...
Currently the backend serves 400 errors as a raw string, e.g. "Not Found: No invite with that ID was found."
The problem however, is that without any structure the frontend isn't able to understand what the error is talking about. (There could be different causes for the 400 error, and we may want to show different error messages to match.) And this "401 / auth error" is another instance of that.
So what if we did the following:
- Update the SSC REST API to no longer return errors (4xx and 5xx responses) as strings, and instead return a well-known error response type. e.g.
{ message: string, cause: string }. - Update the
ssc_proxy.goto surface 401/403 errors in this format as well.
That way, we can use a CodyProApiError type consistently, and not need to special case isCodyProApiAuthError. e.g.
if (subscriptionQueryResult.error) {
if (subscriptionQueryResult.error.cause === 'expired_credentials') {
return <Alert variant="danger">Could you do me a solid and login again?</Alert>
}
return <Alert variant="danger">{ subscriptionQueryResult.error.message }</Alert>
}Ultimately it would be great if we could enforce that if subscriptionQueryResult.error is truthy, then subscriptionQueryResult.error.message will always be safe to show to end users. And if the client wants to handle specific error cases, they can inspect subscriptionQueryResult.error.cause.
What do you think? (It would be more of a pain to make this type of breaking API change after we launch Teams and Invites, so if we think this is something we would like, we should just go ahead and make the change. (See backend/internal/rest/response.go in the SSC codebase, as that's how we render the HTTP response bodies on the backend.)
There was a problem hiding this comment.
So what if we did the following:
Update the SSC REST API to no longer return errors (4xx and 5xx responses) as strings, and instead return a well-known error response type. e.g. { message: string, cause: string }.
Update the ssc_proxy.go to surface 401/403 errors in this format as well.
Sounds good to me! I'd like it implement it in the follow-up PR.
For this PR, I suggest we show custom error messages for each component (example - it what we basically had in the SSC codebase) and handle 401 in a call API utility function (as useApiCaller does). In this way we eliminate weird isCodyProApiAuthError checks. WDYT?
chrsmith
left a comment
There was a problem hiding this comment.
There's a whole lot of good stuff in this PR to discuss, and work through. I'm generally onboard with the idea but with some caveats.
re: Approval, I'll defer to @vdavid
I have ... opinions on whether or not we should rely on a library like react-query or similar.
Personally, I am very nervous when a library/framework does things like "caching" or "resource sharing" because these are things that are critically important to how the product works. And therefore are things that I would want to have direct control over. Even if it means needing to find, fix, debug, and spend months iterating on obscure data races and interactions between async data requests and user navigation to different components.
For example, I -- for better or for worse -- want to know exactly when and under what conditions we make an API call to the server. And any 3rd party layers in-between the user's action and calls to fetch() get in the way of that.
However, given that I am probably in the minority among frontend or full-stack developers in that belief I'm totally happy going along with what y'all think is best. So if you can convince @vdavid this is the way to go, let's do it!
re: this new usage pattern
- useSomeResource hook: Fetches the resource. Regardless of how many components call it, the data would be fetched once, and all instances will get the latest data.
- useUpdateResource hook: Returns methods to mutate the resource and provides the mutation state.
This sounds great. The things you are adding here are easy to understand, and appear straight-forward to use. 👍
However, I think we need to be really careful about caching. It is definitely going to be the case that somebody will modify the Cody Pro team or its subscription outside of the current browser session. And so I'd suggest we err on the side of "refetching the same data" than showing stale-and-incorrect results to users. (Since it's super-trusting and annoying to troubleshoot bugs that suddenly disappear if you press F5.)
meta goal
Again, I'll gladly let you and David figure out what React idioms we want to use for loading data on the frontend. If I were to just explicitly say what I would personally shoot for in terms of requirements:
-
Avoid duplicating "dumb" code. e.g. even in this PR we have both
<LoadingSpinner className="mx-auto" />and<LoadingSpinner />... if we can just have a single place where we define the "loading spinner" that would be great. Same thing for the "if 401 error, then redirect to/sign-out. Because inevitably we'll wind up with a half dozen slightly different variations on that same functionality. -
Eliminate invalid states entirely. This was one of the design goals when adding the
userApiCallerand thatCall<T>abstraction. It's OK to have a single file or module with a bunch of gnarly complexity, but we want to make it hard to screw things up. (e.g. if I calluserQueryto make a POST request... I'm going to have a bad time, aren't I? Can we somehow lean into the type system to eliminate that from happening? See Falling into the Pit of Success.
| <ButtonLink | ||
| variant="primary" | ||
| size="sm" | ||
| href={manageSubscriptionRedirectURL} |
There was a problem hiding this comment.
Totally unrelated to this PR: if I understand what the code is doing here, it seems like using a <ButtonLink> is essentially a "foot gun" if you want to record click events using the telemetry recorder.
So thinking out-loud, would a "proper" fix for this to be making this a first-class thing? e.g. Adding a new property to <ButtonLink> like:
...
recordClickEvent={ telemetryRecorder, event: 'cody.manageSubscription' }
...That way, assuming you notice the recordClickEvent property, we don't need to deal with the confusing interactions between setting href AND onClick?
There was a problem hiding this comment.
using a is essentially a "foot gun" if you want to record click events using the telemetry recorder
Can you elaborate on why it's dangerous, please?
would a "proper" fix for this to be making this a first-class thing? e.g. Adding a new property to like
ButtonLink is a wildcard component and IMO it's hard to justify introducing such a specific prop. Possible counter arguments: "do we need a record select/hover/focus/blur event handler too?"
|
|
||
| // Once loaded, we consider query result to be stable. No need to refetch it again. | ||
| // See: https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults | ||
| staleTime: Infinity, |
There was a problem hiding this comment.
😬 Didn't Justin Bieber teach us to "Never Say Never"? 🙃
If we are talking about defaults here, we cannot say this is universally true. Even for basic HTTP GET requests, things may change out from underneath the user. (And 😨 would it ever retry a POST/PATCH request because it thought things were "stale"?!?)
It seems like this could easily lead to bugs and problems.
Thank you for linking to the docs here, super-duper helpful! Perhaps we should instead take a more nuanced stance like:
// We cache data for only a few minutes before we consider it stale.
// But the library will refetch it anyways in the event of network
// reconnect, window refocus, etc.
// See: https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults
staleTime: fiveMinutesInSeconds,There was a problem hiding this comment.
Makes sense.
Query instances via useQuery or useInfiniteQuery by default consider cached data as stale.
Let's stick to ☝🏻 defaults until we need something special here.
| class CodyProApiAuthError extends Error { | ||
| constructor(m: string) { | ||
| super(m) | ||
|
|
||
| Object.setPrototypeOf(this, CodyProApiAuthError.prototype) | ||
| } | ||
| } | ||
|
|
||
| const isCodyProApiAuthError = (err?: Error): err is CodyProApiAuthError => err instanceof CodyProApiAuthError | ||
|
|
||
| /** | ||
| * Returns an error message based on the type of error encountered. | ||
| * | ||
| * If the error is a specific Cody Pro API error (e.g., `CodyProApiAuthError` as result of expired SAMS credentials), | ||
| * it returns the error message associated with that error. Otherwise, it returns a custom caller-provided message. | ||
| * | ||
| * @param err - The error object encountered, if any. | ||
| * @param message - The custom message to return if the error is not a specific Cody Pro API authentication error. | ||
| * @returns The error message to display. | ||
| */ | ||
| export const getCodyProApiErrorMessage = (err: Error | null, message: string): string => { | ||
| if (!err) { | ||
| return '' | ||
| } | ||
|
|
||
| if (isCodyProApiAuthError(err)) { | ||
| return err.message | ||
| } | ||
|
|
||
| return message | ||
| } | ||
|
|
||
| // Wrapper around `apiCaller.call` which throws an error in case of a non-2xx response. | ||
| // Motivation taken from here: https://tanstack.com/query/latest/docs/framework/react/guides/query-functions#usage-with-fetch-and-other-clients-that-do-not-throw-by-default | ||
| const callCodyProApi = async <Data>(call: Call<Data>): Promise<{ data?: Data; response: Response }> => { | ||
| const result = await apiCaller.call(call) | ||
|
|
||
| if (!result.response.ok) { | ||
| if (result.response.status === 401) { | ||
| throw new CodyProApiAuthError('Unauthorized. Please log out and log back in.') | ||
| } | ||
| throw new Error(`Cody Pro API call failed with status ${result.response.status}`) | ||
| } | ||
|
|
||
| return result | ||
| } |
There was a problem hiding this comment.
I am 100,000% in support of adding more formality / types to describe the errors we receive. But let me suggest something slightly different...
Currently the backend serves 400 errors as a raw string, e.g. "Not Found: No invite with that ID was found."
The problem however, is that without any structure the frontend isn't able to understand what the error is talking about. (There could be different causes for the 400 error, and we may want to show different error messages to match.) And this "401 / auth error" is another instance of that.
So what if we did the following:
- Update the SSC REST API to no longer return errors (4xx and 5xx responses) as strings, and instead return a well-known error response type. e.g.
{ message: string, cause: string }. - Update the
ssc_proxy.goto surface 401/403 errors in this format as well.
That way, we can use a CodyProApiError type consistently, and not need to special case isCodyProApiAuthError. e.g.
if (subscriptionQueryResult.error) {
if (subscriptionQueryResult.error.cause === 'expired_credentials') {
return <Alert variant="danger">Could you do me a solid and login again?</Alert>
}
return <Alert variant="danger">{ subscriptionQueryResult.error.message }</Alert>
}Ultimately it would be great if we could enforce that if subscriptionQueryResult.error is truthy, then subscriptionQueryResult.error.message will always be safe to show to end users. And if the client wants to handle specific error cases, they can inspect subscriptionQueryResult.error.cause.
What do you think? (It would be more of a pain to make this type of breaking API change after we launch Teams and Invites, so if we think this is something we would like, we should just go ahead and make the change. (See backend/internal/rest/response.go in the SSC codebase, as that's how we render the HTTP response bodies on the backend.)
…anagement-page-with-react-query
…anagement-page-with-react-query
|
@chrsmith, thanks for your review!
Valid concern. Rolled back to the defaults https://github.com/sourcegraph/sourcegraph/pull/62939#discussion_r1624421063.
I agree with the 401 handling as mentioned in (https://github.com/sourcegraph/sourcegraph/pull/62939#discussion_r1624754215). It’s an app-level (I mean Cody Pro app) issue since we can’t make calls to the Cody Pro backend without a valid token.
The custom query hooks still use Instead of using a custom hook like Additionally, useQuery can be used with POST requests. In fact, most (if not all) usages of useQuery (Apollo one) in our codebase are POST requests to our GraphQL API. But with the current subscription endpoint, POST of course will result in an error. So it's up to developer to ensure it's used properly. |
vdavid
left a comment
There was a problem hiding this comment.
Very nice! I like the simplicity, and all the extra goodies this comes with. Left a few nits.
…anagement-page-with-react-query
Follow-up on https://github.com/sourcegraph/sourcegraph/pull/62754#discussion_r1615890801.
Our current utilities,
useSSCQueryanduseApiCaller, make requests to the Self-Serve Cody backend withinuseEffecthooks. These utilities, aside from the downsides mentioned in the React docs, lack important features such as:Implementing these features ourselves would be time-consuming and challenging to handle all edge cases.
I propose using a data-fetching library, specifically react-query, to manage server state on the client. This article provides a detailed overview of the benefits of this approach.
Additionally, we already use a similar approach with our GraphQL API using
@apollo/client(re-exported by@sourcegraph/http-clientlib), which providesuseQueryanduseMutationhooks. This will make the adoption smoother as most engineers who have worked on our frontend codebase are already familiar with the concept.This PR demonstrates the difference between using custom data-fetching hooks we developed and those provided by a third-party state management library. Notice the main advantages it brings to managing state on the client:
useSomeResourcehook: Fetches the resource. Regardless of how many components call it, the data would be fetched once, and all instances will get the latest data. This PR adds theuseCurrentSubscriptionhook, but we will likely wantuseInvoices,useSomethingElse, etc. For more details on the available APIs, check theuseQuerydocs.useUpdateResourcehook: Returns methods to mutate the resource and provides the mutation state. This PR addsuseUpdateCurrentSubscriptionhook, but we'll likely needuseUpdateTeam,useUpdateSomethingElsehooks. For more details, check theuseMutationdocs.If we agree on this approach we can eliminate
useSSCQueryanduseApiCallerhook in favor of using the custom query/mutation hooks which usereact-queryhooks under the hood.Test plan