Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

PLG: use react-query for async state management#62939

Merged
taras-yemets merged 51 commits into
mainfrom
ty/plg-subscription-management-page-with-react-query
Jun 5, 2024
Merged

PLG: use react-query for async state management#62939
taras-yemets merged 51 commits into
mainfrom
ty/plg-subscription-management-page-with-react-query

Conversation

@taras-yemets

@taras-yemets taras-yemets commented May 28, 2024

Copy link
Copy Markdown
Contributor

Follow-up on https://github.com/sourcegraph/sourcegraph/pull/62754#discussion_r1615890801.

Our current utilities, useSSCQuery and useApiCaller, make requests to the Self-Serve Cody backend within useEffect hooks. These utilities, aside from the downsides mentioned in the React docs, lack important features such as:

  • Triggering query re-fetch
  • Caching
  • Resource sharing between components
  • Race condition protection
  • Handling mutations
  • Additional features like request cancellation, success/error/settled handlers, etc.

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-client lib), which provides useQuery and useMutation hooks. 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:

  1. 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. This PR adds the useCurrentSubscription hook, but we will likely want useInvoices, useSomethingElse, etc. For more details on the available APIs, check the useQuery docs.
  2. useUpdateResource hook: Returns methods to mutate the resource and provides the mutation state. This PR adds useUpdateCurrentSubscription hook, but we'll likely need useUpdateTeam, useUpdateSomethingElse hooks. For more details, check the useMutation docs.

If we agree on this approach we can eliminate useSSCQuery and useApiCaller hook in favor of using the custom query/mutation hooks which use react-query hooks under the hood.

Test plan

…cegraph/sourcegraph into ty/plg-subscription-management-page
Comment on lines +151 to +153
const isLoading = isStripeLoading || updateCurrentSubscriptionMutation.isPending
const errorMessage =
stripeErrorMessage || updateCurrentSubscriptionMutation.isError ? updateSubscriptionMutationErrorText : ''

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines -78 to -176
<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>
)}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 vdavid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@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'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ignore changes to useApiClient.tsx from this PR. It's just reverted to the main rev.

@taras-yemets taras-yemets changed the title Ty/plg-subscription-management-page-with-react-query PLG: use react-query for async state management May 28, 2024
Comment on lines +14 to +59
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
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, now I see. Thank you!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. 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 }.
  2. Update the ssc_proxy.go to 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.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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?

Base automatically changed from ty/plg-subscription-management-page to main May 30, 2024 12:07

@chrsmith chrsmith left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  1. 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.
  2. 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 userApiCaller and that Call<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 call userQuery to 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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

😬 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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +14 to +59
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. 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 }.
  2. Update the ssc_proxy.go to 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.)

Comment thread client/web/src/cody/management/api/react-query/subscriptions.ts Outdated
@taras-yemets

Copy link
Copy Markdown
Contributor Author

@chrsmith, thanks for your review!

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.

Valid concern. Rolled back to the defaults https://github.com/sourcegraph/sourcegraph/pull/62939#discussion_r1624421063.

Avoid duplicating "dumb" code. e.g. even in this PR we have both and ... 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.

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.
However, handling loading states requires a more nuanced approach. For instance, when loading subscription data for the first time, the entire page should be in a loading state as there’s nothing to display yet. On the other hand, when re-fetching subscription data due to refocusing, data becoming stale, or mutations, we might want to manage the loading state differently or even ignore it altogether.

Eliminate invalid states entirely. This was one of the design goals when adding the userApiCaller and that Call 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 call userQuery to 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?

The custom query hooks still use Call<T> under the hood.

Instead of using a custom hook like useCurrentSubscription, one can import useQuery directly and potentially use it incorrectly (e.g., with the wrong method, URL, etc.). It’s up to the caller to ensure it’s used properly. From our side, we can document the current approach, such as recommending using custom hooks for fetching and mutating resources via the Cody Pro API, and add tests to hooks and components. This way, we can ensure 🤞🏻 that the useCurrentSubscription hook (for example) uses the correct method and URL, and returns data of the appropriate shape.
However, implementing a custom lint rule to forbid direct useQuery usage would be overkill, in my opinion, as it’s a completely valid method to use.

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.

@taras-yemets taras-yemets requested a review from vdavid June 4, 2024 10:50

@vdavid vdavid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Very nice! I like the simplicity, and all the extra goodies this comes with. Left a few nits.

Comment thread client/web/src/cody/management/api/react-query/callCodyProApi.ts Outdated
Comment thread client/web/src/cody/management/api/react-query/subscriptions.ts
@taras-yemets taras-yemets enabled auto-merge (squash) June 4, 2024 16:34
@taras-yemets taras-yemets merged commit 231c312 into main Jun 5, 2024
@taras-yemets taras-yemets deleted the ty/plg-subscription-management-page-with-react-query branch June 5, 2024 07:18
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants