-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
Changed typings of updateQuery's previousQueryResult to be potentially undefined #12276
Changed typings of updateQuery's previousQueryResult to be potentially undefined #12276
Conversation
👷 Deploy request for apollo-client-docs pending review.Visit the deploys page to approve it
|
|
🦋 Changeset detectedLatest commit: b817562 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
3a4c44f
to
d9cfe5d
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm generally ok with this change, but would you be willing to add a test that demonstrates the case where previousData
is undefined
? I'd like to make sure its "documented" in some form through runtime behavior, not just the types. That should help us prevent regressions in the future. That would also help determine whether this is an actual bug, or if this behavior was intentional. I'd like to avoid a band-aid to the types just in case previousData
was never intended to be undefined
.
Thanks for the contribution!
src/__tests__/subscribeToMore.ts
Outdated
@@ -241,6 +241,9 @@ describe("subscribeToMore", () => { | |||
} | |||
`, | |||
updateQuery: (prev, { subscriptionData }) => { | |||
if (!prev) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I definitely understand why you did this for this test, but unfortunately this makes it more difficult to know if we introduced regressions by accidentally making prev
undefined when it should have a value. The assertion below isn't run in that case, so it would appear that the test would still pass (or at the very least, the source of where the test would fail would be further away from the actual problem).
Instead, I'd recommend updating the prev
to prev!
, that way this test will crash if this ever switches to undefined
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sense, I wasn't 100% sure how to handle it, but you're right the expectation is that prev
should be provided here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would you be willing to add a test that demonstrates the case where previousData is undefined
If you read the issue, the problem is that we can't figure out the repro steps to have undefined
at runtime.
It is likely a race condition that might be hard to reproduce in a testing environment.
I admit I haven't tried recently, this is an issue we've faced years ago and kept the patch.
I can try to get a repro, but I can't make any guarantee
src/__tests__/subscribeToMore.ts
Outdated
@@ -241,6 +241,9 @@ describe("subscribeToMore", () => { | |||
} | |||
`, | |||
updateQuery: (prev, { subscriptionData }) => { | |||
if (!prev) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sense, I wasn't 100% sure how to handle it, but you're right the expectation is that prev
should be provided here
Ah shoot. I read that and it went right over my head. Let me play around with this a bit more and see if I can think of any way this could happen. I'll push a test if I can, or respond back and let you know that I couldn't come up with something. |
@Cellule the closest I can get to reproducing this is
apollo-client/src/core/ObservableQuery.ts Lines 732 to 737 in de8196e
That Is there anything else you can tell me about the queries where you see this happen, such as directives used, type policies, etc? Perhaps the combination of one of those things hits this case. |
I admit it's been a long time since I've seen the issue because we've mitigated it by always checking |
That would be super helpful if you can. I fear this change is more of a band-aid to the real underlying issue and would love to figure that out if we can. Otherwise this could be a fairly disruptive change to a lot of existing users who could now potentially see a lot of TypeScript errors after upgrading. |
Ok great! I was hoping that would be the case and this one actually makes a lot more sense to me. I could reproduce this fairly easily in a test by starting a query, subscribing to the subscription, then having the subscription emit an event before the query finished on the network. In this case, no data had been written to the cache from the query, so I think the right change here might be to wrap As for the other behavior you're seeing, it looks like |
There's still something that bugs me with the The scenario seems to be as follows
Real quick it sounds like it would be "safe" to simply not call Another thing to mention, the makeSubscription<
IWorkOrderDetailsQueryData,
IWoMessageUpsertedSubscriptionSubscription,
IWoMessageUpsertedSubscriptionSubscriptionVariables
>("messageUpsertedSubscription", query.subscribeToMore, {
document: messageUpsertedSubscription,
variables: {
parent: { id: workOrderId, type: IUserMessageParentType.WorkOrder },
},
updateQuery(prev, { subscriptionData, variables }) {
const newMessage = subscriptionData?.data?.messageUpserted?.message;
const workOrderUpdates = subscriptionData?.data?.messageUpserted?.parent;
const transcription = subscriptionData.data.messageUpserted?.transcription;
if (
(variables as any as IWorkOrderDetailsQueryVariables).id !==
(workOrderUpdates as any).id
) {
debugger;
return prev;
}
if (!newMessage || workOrderUpdates?.__typename !== "WorkOrder" || !prev?.workOrder) {
return prev!;
} |
I go back and forth on this. This sounds like a reasonable change, but I fear its too much of a breaking change for a patch/minor release. Since we don't provide any additional information in the callback (i.e. whether the previous result is complete), there could be cases where suddenly your callback is no longer called and its difficult to tell why. That said, the callback does take an Looking at the return type of The tricky part would be updating the TypeScript type here. It would be ideal if it were something like this: updateQuery(previous, { complete }) {
if (complete) {
previous;
// ^? TData
}
previous;
// ^? DeepPartial<TData>
} Thoughts on this? |
Yeah this is exactly why I'm on the fence with the change as well
Humm yeah that could work, but indeed the typings would be the tricky part
Right, I did want to update at least the return type which is more accurate and backward compatible.
Keeping backward compatibility and typescript relation between the 2 variables sounds like a challenge! |
Definitely unfortunate, but I'd wager its more preferable than a crash in production! |
3188a44
to
179608a
Compare
commit: |
Alright here's my attempt This shows my reasoning best I think const updateQuery: Parameters<typeof observable.updateQuery>[0] = jest.fn(
(previousResult) => {
expect(previousResult.complete).toBe(true);
// Type guard
if (!previousResult.complete) {
return undefined;
}
return { user: { ...previousResult.user, name: "User (updated)" } };
}
); There's no way I can think of to bind the type of a parameter with a type guard on another parameter. I cleaned up a bunch of types that we're not shared where it should Let me know what you think |
This is turning out to be a much more difficult change than expected 🙃. Unfortunately adding a Unfortunately changing that first argument in any capacity is a breaking change. That said, how urgent is this change for you? We have started working on v4 and we could certainly think about moving this change there. I'd be open to redesigning the Thoughts? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok this should be my last round of feedback. This is looking great though otherwise! Really appreciate the back and forth.
Let me know if you'd like me to make the updates and I'd be happy to push the changes so we can get this across the finish line. I'm planning to release a 3.13 beta in the next day or two and would love to have this in there 🙂
src/core/watchQueryOptions.ts
Outdated
* Might have partial or missing data. | ||
*/ | ||
complete: false; | ||
previousQueryResult: DeepPartial<Unmasked<TData>> | undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know the original argument name was previousQueryResult
, but can we call this previousData
instead? This value isn't really a "query result" (i.e. QueryResult
or ApolloQueryResult
type) but rather the actual data
instead. We use previousData
in other places (e.g. useQuery
) so it will align nicely there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
src/core/ObservableQuery.ts
Outdated
const variables = (this as any).variables; | ||
let updateOptions: UpdateQueryOptions<TData, TVariables>; | ||
if (complete && result) { | ||
updateOptions = { | ||
variables, | ||
complete: true, | ||
previousQueryResult: result, | ||
}; | ||
} else { | ||
updateOptions = { | ||
variables, | ||
complete: false, | ||
previousQueryResult: result as DeepPartial<Unmasked<TData>>, | ||
}; | ||
} | ||
|
||
const newResult = mapFn(result!, updateOptions); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm guessing you did this to ensure TypeScript was happy with the values. While that makes sense, I think we're introducing additional bundle size for an internal implementation detail. In this case, I think it is ok to use a type cast so that we can inline these options.
const variables = (this as any).variables; | |
let updateOptions: UpdateQueryOptions<TData, TVariables>; | |
if (complete && result) { | |
updateOptions = { | |
variables, | |
complete: true, | |
previousQueryResult: result, | |
}; | |
} else { | |
updateOptions = { | |
variables, | |
complete: false, | |
previousQueryResult: result as DeepPartial<Unmasked<TData>>, | |
}; | |
} | |
const newResult = mapFn(result!, updateOptions); | |
const newResult = mapFn(result!, { | |
variables: this.variables, | |
complete: !!complete, | |
previousData: result | |
} as UpdateQueryOptions<TData, TVariables>); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was really trying to avoid the typecast, but it's true that the runtime is actually the same in both branches
src/core/ObservableQuery.ts
Outdated
const { queryManager } = this; | ||
const { result } = queryManager.cache.diff<TData>({ | ||
const { result, complete } = queryManager.cache.diff<Unmasked<TData>>({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good find on this type issue! Can we revert this to TData
for now? I'd rather fix this at the core rather than this one place since this is an issue for cache.diff
in general. This was an oversight on my part when I was updating all the cache types to use Unmask
. Let's fix this in a separate PR.
const { result, complete } = queryManager.cache.diff<Unmasked<TData>>({ | |
const { result, complete } = queryManager.cache.diff<TData>({ |
I think the type cast suggestion in my other comment will likely "fix" the TypeScript error you see when reverting this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This type change also affects the first argument of the mapFn which was being cast to Unmasked<TData>
In essence I just moved the cast from 1 place to another
If I change that back to TData
then I'll have to put back result! as Unmasked<TData>
below
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add a TODO
comment then so I don't forget to remove this when I get around to fixing that type if thats ok with you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah ok I understand better your point, you mean cache.diff<TData>
should likely return type Unmasked<TData>
for result. Ok fair I'll revert
src/__tests__/ApolloClient.ts
Outdated
>(); | ||
} | ||
|
||
return undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm, I'd like to avoid the need to return undefined
explicitly in the case that you want to skip the update as this is just noise for no reason. Trying this out locally, looks like if we switch the return type to return | void
instead of | undefined
, this will work as expected. Can we do that instead so that users can just return
early without the undefined
value or omit return
altogether?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair, I considered it but wasn't sure if we preferred it
src/core/watchQueryOptions.ts
Outdated
@@ -164,31 +164,98 @@ export interface FetchMoreQueryOptions<TVariables, TData = any> { | |||
context?: DefaultContext; | |||
} | |||
|
|||
export type UpdateQueryFn< | |||
export interface UpdateQueryFn< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this type is publicly exported, this change is a breaking change to this type so I think we'll unfortunately need to leave this alone. If we need to migrate away from it in favor of a new name, let's deprecate it instead and direct users to use the new type (I think SubscribeToMoreUpdateQueryFn
replaces this if I'm reading it right). That or I'd revert this change completely and we can change the name of this in v4.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm confused by my changes at this point, will push the other fixes and review this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I checked again and I'm not 100% sure anymore. Public types are types exported from src/core/index.ts right? If so then the type we need to be backward compatible is UpdateQueryOptions
not UpdateQueryFn
I essentially added complete
and previousData
to UpdateQueryOptions
which I think is backward compatible right?
What are your rules when it comes to exporting types publicly?
I think I added a few, maybe too many?
return internalQueryRef.observable.subscribeToMore(options); | ||
return internalQueryRef.observable.subscribeToMore( | ||
// TODO: The internalQueryRef doesn't have TVariables' type information so we have to cast it here | ||
options as any as SubscribeToMoreOptions<TData, OperationVariables> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
options as any as SubscribeToMoreOptions<TData, OperationVariables> | |
options as SubscribeToMoreOptions<TData, OperationVariables> |
Looks like this works without needing to cast to as
first 🙂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you are right about the other call sites, but here there seems to be an incompatibility due to TSubscriptionData vs TData
Conversion of type 'SubscribeToMoreOptions<TData, TSubscriptionVariables, TSubscriptionData, TVariables>' to type 'SubscribeToMoreOptions<TData, OperationVariables, TData, OperationVariables>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
Type 'TSubscriptionData' is not comparable to type 'TData'.
'TData' could be instantiated with an arbitrary type which could be unrelated to 'TSubscriptionData'.ts(2352)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah shoot. I tried this locally in a few areas and they all worked but I must not have tried this one 😆
subscribeToMore: internalQueryRef.observable.subscribeToMore, | ||
// TODO: The internalQueryRef doesn't have TVariables' type information so we have to cast it here | ||
subscribeToMore: internalQueryRef.observable | ||
.subscribeToMore as any as SubscribeToMoreFunction<TData, TVariables>, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.subscribeToMore as any as SubscribeToMoreFunction<TData, TVariables>, | |
.subscribeToMore as SubscribeToMoreFunction<TData, TVariables>, |
src/__tests__/ApolloClient.ts
Outdated
>(); | ||
} | ||
|
||
return undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair, I considered it but wasn't sure if we preferred it
src/core/watchQueryOptions.ts
Outdated
subscriptionData: { data: Unmasked<TSubscriptionData> }; | ||
variables?: TSubscriptionVariables; | ||
subscriptionVariables: TSubscriptionVariables | undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure I can remove this part
src/core/watchQueryOptions.ts
Outdated
/** | ||
* @deprecated Use `options.previousQueryResult` instead. | ||
*/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!
src/core/watchQueryOptions.ts
Outdated
* Might have partial or missing data. | ||
*/ | ||
complete: false; | ||
previousQueryResult: DeepPartial<Unmasked<TData>> | undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
src/core/watchQueryOptions.ts
Outdated
* @deprecated Use `options.previousQueryResult` instead. | ||
*/ | ||
previousQueryResult: Unmasked<TData>, | ||
options: TOptions & UpdateQueryOptions<TData, TVariables> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
src/core/ObservableQuery.ts
Outdated
@@ -538,6 +537,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`, | |||
optimistic: false, | |||
}, | |||
(previous) => | |||
// REVIEW: Code smell here with the `!` and cast, is it possible for previous to be null or have partial data ? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jerelmiller what do you think about this?
Do you want to keep the comment or remove it and address this separately?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's address separately since it was already there. Feel free to remove this for now. cache.updateQuery
might be a whole other animal.
src/core/ObservableQuery.ts
Outdated
const { queryManager } = this; | ||
const { result } = queryManager.cache.diff<TData>({ | ||
const { result, complete } = queryManager.cache.diff<Unmasked<TData>>({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This type change also affects the first argument of the mapFn which was being cast to Unmasked<TData>
In essence I just moved the cast from 1 place to another
If I change that back to TData
then I'll have to put back result! as Unmasked<TData>
below
src/core/ObservableQuery.ts
Outdated
const variables = (this as any).variables; | ||
let updateOptions: UpdateQueryOptions<TData, TVariables>; | ||
if (complete && result) { | ||
updateOptions = { | ||
variables, | ||
complete: true, | ||
previousQueryResult: result, | ||
}; | ||
} else { | ||
updateOptions = { | ||
variables, | ||
complete: false, | ||
previousQueryResult: result as DeepPartial<Unmasked<TData>>, | ||
}; | ||
} | ||
|
||
const newResult = mapFn(result!, updateOptions); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was really trying to avoid the typecast, but it's true that the runtime is actually the same in both branches
src/core/watchQueryOptions.ts
Outdated
@@ -164,31 +164,98 @@ export interface FetchMoreQueryOptions<TVariables, TData = any> { | |||
context?: DefaultContext; | |||
} | |||
|
|||
export type UpdateQueryFn< | |||
export interface UpdateQueryFn< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm confused by my changes at this point, will push the other fixes and review this
return internalQueryRef.observable.subscribeToMore(options); | ||
return internalQueryRef.observable.subscribeToMore( | ||
// TODO: The internalQueryRef doesn't have TVariables' type information so we have to cast it here | ||
options as any as SubscribeToMoreOptions<TData, OperationVariables> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you are right about the other call sites, but here there seems to be an incompatibility due to TSubscriptionData vs TData
Conversion of type 'SubscribeToMoreOptions<TData, TSubscriptionVariables, TSubscriptionData, TVariables>' to type 'SubscribeToMoreOptions<TData, OperationVariables, TData, OperationVariables>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
Type 'TSubscriptionData' is not comparable to type 'TData'.
'TData' could be instantiated with an arbitrary type which could be unrelated to 'TSubscriptionData'.ts(2352)
Alright I think it mostly looks good. |
Sounds great! Really appreciate the back and forth. I'll make sure this change makes it into the 3.13 beta 🙂. Hope the presentation goes well! Feel free to ping me on Discord ( |
670f112
into
apollographql:release-3.13
Related to #12228
There's an outstanding issue where
previousQueryResult
sometimes, flakily, ends upundefined
This change aims to have the typings reflect the actual runtime behavior allowing devs to write safe code that can handle the missing
previousQueryResult
An alternative could be to simply not call
mapFn
ifpreviousQueryResult
is missing. While neither is ideal nor fixes the underlying issue of "why"previousQueryResult
isundefined
. At least it would avoid bad crashes.I've been rolling with this patch for years now and I haven't seen any bad side-effects so far. As far as I can tell, the case where
previousQueryResult
can be safely ignored and is likely going to be called again with actual data when it mattersTypings Update for
updateQuery
:updateQuery
'spreviousQueryResult
to be potentially undefined in@apollo/client
.updateQuery
method insrc/core/ObservableQuery.ts
to reflect the new typings.UpdateQueryFn
type insrc/core/watchQueryOptions.ts
to allowpreviousQueryResult
to be undefined.ObservableQueryFields
interface insrc/react/types/types.ts
to reflect the new typings.VSCode Configuration:
editor.codeActionsOnSave
setting to disable organizing imports on save in.vscode/settings.json
.