Closed hickscorp closed 2 months ago
I fear I'm still missing a bit of context here. Can you maybe take a few steps back and start describing what you want to do in the end?
That said:
useEffect
with a subscription result - you probably want to use the onData
callbackuseEffect
to trigger a lazyQuery. useLazyQuery
should respond to user interaction, not to React renders. You probably want to use useQuery
with a skip option instead.Good morning and thank you @phryneas .
Conversation
that has a connection to edges of type Step
.Steps
inside a Conversation
. It has some logic to fetchMore
by shifting over the PageInfo
cursor. It's really just a query that goes something like query Steps(...) { node(id: ...) { ... on Conversation { steps(last: ..., before: ... { ... } ) } } }
Step
becomes part of a Conversation
remotelly, maybe created by another user.Step
that was added, and we want to add it to the Apollo cache - the one being used behind the scenes, being primed and filled by the page listing the Step
entities.So from what we understood, a good way to do so would be to use cache.modify
on the Connection
edges to append the Step
received by the subscription to the edges that were already fetched.
What I don't understand is how to do this in a "type safety" fashion, or even how to approach the data structures that should be involved in pushing new elements to the cache. It'd be nice to have a working example of such scenario.
In response to your comments:
useLazyQuery
lightly in useEffect
. Sometimes it's necessary - and with the right dependencies we never had problems with that. I'm curious why you flag this though, would you care to ellaborate please? In our case, using useQuery
along with the skip
flag isn't an option, because we can't have this useQuery
declared upfront with the variables - as our component is mounted with these variables being optional - and therefore can't compile without them being set to a value. This is what we want from the strong typing guarantees at compile-time - and instead we want the query to only fire when the variables become set. We also don't want to start getting into "nested nested components" hell so that we can have a conditionally rendered component that useQuery
internally, it essentially results in the same internal logic with more complexity in our code.This is what we want from the strong typing guarantees at compile-time - and instead we want the query to only fire when the variables become set.
Understood. Personally, I would prefer the better "runtime behaviour" over the type safety here, but that's very opinionated.
In the future, you'll be able to do this in a more typesafe matter using skipToken
, which is already available with useSuspenseQuery
and which we will add to useQuery
in the future.
Generally, you're in a bit of an edge case situation regarding type safety - usually, you'd do something like this:
cache.modify<EntityType>({
id: ...,
fields: {
fieldName(valueOrRef) {
return differentValue
}
}
});
You can see more usage examples in our tests, e.g. at https://github.com/apollographql/apollo-client/blob/9dc45bb8d13ebb486dd1cb9963e1e0d9787d984d/src/cache/core/__tests__/cache.ts#L348-L383
Your problem here is that your have deeper nesting here - which the cache will try to normalize, and if it's successful, nested fields like steps.edges
here will only be another reference.
So depending on how your cache can be normalized, you'd have to actually call cache.modify
for the instance stored in sub.data.conversationStepCreated.conversation.steps
instead, with the appropriate type for that.
Thansk a lot @phryneas this is helpful.
I'm trying to simplify the codebase to give a clearer example, as it seems that the question was misunderstood. Will come back to you soon guys :)
Ok, so I think I found a way to ask way, way more simply.
Imagine a Post
object with a connection
field comments
of type Comment
.
The client lists these comments by performing a "one shot" query to the node(id: postId)
field. Classic.
At the same time, it subscribes to something like CommentCreated(postId: ...)
which gives back a Comment
every time one is added to this given Post
identified by its id
.
I would imagine that the Apollo client cache would be configured with something like this:
new InMemoryCache({
fragments: createFragmentRegistry(),
typePolicies: {
Post: {
fields: {
comments: relayStylePagination(),
},
},
}
Cool.
You see - we were successful having these realtime comments added to the Post page - but we are adding them to a state array - not to the apollo cache backing the Post's comments connection. So when the user leaves the page and comes back, the state of the cache is shown without these new entries.
So the simplified question is: when the CommentCreated
subscription fires, how do you append that new post to the existing list in the Apollo cache (using TypeScript and with type guarantees)?
We've been successful with modifying the cache, with something like this - this being the function that is called when the subscription fires:
const onNewComment = (comment: DetailedCommentFragment) => {
cache.modify({
id: cache.identify({
__typename: "Post",
id: comment.post.id,
}),
fields: {
comments(existing) {
return {
...existing,
edges: [...existing.edges, { node: comment }],
};
},
},
});
The proble here is that existing
is any
. There are no guarantees at all, and it feels like blind luck that it works because it could in fact be a concrete CommentConnection
but it could also be a Reference
...
The moment we add a type hint to cache.modify<Post>(...)
to our code, it breaks because we're not handling Reference
... So we played around with readField
but can't seem to succeed with finding the right types to use.
Any sample code that would help deal with the Relay types (connections, edges having a cursor etc and appending to them) could be useful here.
In our case and translating Post
into Conversation
and Comment
into Step
with its connection on Post
being steps
:
// Whenever a subscription catches a new step, that's the handler.
const onNewStep = (step: DetailedStepFragment) => {
cache.modify<Conversation>({
id: cache.identify({
__typename: "Conversation",
id: step.conversation.id,
}),
fields: {
steps(existing, { readField }) {
const edges = readField("edges", existing) || [];
return {
...existing,
edges: [...edges, { node: step }],
};
},
},
});
This won't compile - because readField
gives us a string | number | void | Readonly<Object> | Readonly<Reference> | readonly string[] | readonly Reference[] | null | undefined
which doesn't match at all what we would expect - probably a ReadonlyArray<ConversationStepEdge>
?
But if we hard-code the hint (Eg readField<ReadonlyArray<ConversationStepEdge>>("edges", existing);
does it really guarantee that it can be that and only that?
Here's what happens when we try:
// Whenever a subscription catches a new step, that's the handler.
const onNewStep = (step: DetailedStepFragment) => {
cache.modify<Conversation>({
id: cache.identify({
__typename: "Conversation",
id: step.conversation.id,
}),
fields: {
steps(existing, { readField }) {
const edges = readField<ReadonlyArray<ConversationStepEdge>>(
"edges",
existing
);
return edges
? {
...existing,
edges: [...edges, { node: step }],
}
: existing;
},
},
});
The error is:
Type '(existing: Reference | AsStoreObject<ConversationStepConnection>, { readField }: ModifierDetails) => Reference | { ...; } | { ...; }' is not assignable to type 'Modifier<Reference | AsStoreObject<ConversationStepConnection>>'.\n Type 'Reference | { edges: (ConversationStepEdge | { node: DetailedStepFragment; })[]; __ref: string; } | { edges: (ConversationStepEdge | { ...; })[]; __typename?: \"ConversationStepConnection\" | undefined; pageInfo: PageInfo; }' is not assignable to type 'Reference | AsStoreObject<ConversationStepConnection> | DeleteModifier | InvalidateModifier'.\n Type '{ edges: (ConversationStepEdge | { node: DetailedStepFragment; })[]; __typename?: \"ConversationStepConnection\" | undefined; pageInfo: PageInfo; }' is not assignable to type 'Reference | AsStoreObject<ConversationStepConnection> | DeleteModifier | InvalidateModifier'.\n Type '{ edges: (ConversationStepEdge | { node: DetailedStepFragment; })[]; __typename?: \"ConversationStepConnection\" | undefined; pageInfo: PageInfo; }' is not assignable to type 'AsStoreObject<ConversationStepConnection>'.\n Types of property 'edges' are incompatible.\n Type '(ConversationStepEdge | { node: DetailedStepFragment; })[]' is not assignable to type 'ConversationStepEdge[]'.\n Type 'ConversationStepEdge | { node: DetailedStepFragment; }' is not assignable to type 'ConversationStepEdge'.\n Property 'cursor' is missing in type '{ node: DetailedStepFragment; }' but required in type 'ConversationStepEdge'.
Thanks a lot!
Also, looking at https://github.com/apollographql/apollo-client/blob/main/src/utilities/policies/pagination.ts#L94 it seems that this could do exactly what we're looking for - but we have no idea on how to use it after the subscription fires.
Ok - a bit of progress.
It seems that we're getting a bit closer to type safety, if we use cache.updateQuery
instead of cache.modify
.
Would this be a good way to achieve what we want? It seems to be working in a (very) controlled environment.
We were expecting that using cache.updateQuery
would let the field policy kick-in - so that we wouldn't need to do the merges ourselves. But it doesn't - so we ended up with:
Seems to us that it will break - because all the logic that relayStylePagination()
would do is completely bypassed.
WDYT?
const onNewStep = (step: DetailedStepFragment) => {
cache.updateQuery(
{
query: GQL.Conversation.StepsQuery,
variables: { id: step.conversation.id, last: 8 },
},
(existing) => {
if (existing?.node?.__typename !== "Conversation") return;
const node = frag(GQL.Conversation.WithSteps, existing.node);
return {
...existing,
node: {
...node,
steps: {
...node.steps,
edges: [
...node.steps.edges,
{ __typename: "ConversationStepEdge", node: step },
],
},
},
};
}
);
};
I'm sorry, I dropped the ball on this issue, just too much to do in too little time :(
Did your solution work out for you in the end?
Also, as a heads up, we recently released Version 3.11 with a rewrite of useSubscription
. It comes with an ignoreResults
option, so you could set that to true
and move your effect into the onData
option callback. That would give you more control over component rerenders.
We're closing this issue now but feel free to ping the maintainers or open a new issue if you still need support. Thank you!
This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. For general questions, we recommend using StackOverflow or our discord server.
We have a subscription that notifies us when something new is added to a connection. In our React component tree, we have something like this:
When the subscription ticks, we receive a
Step
entity, which should be added to its parentConversation.steps
connection in the cache so that UI updates automatically.I am failing to understand how to use
cache.modify
to do so:EDIT: We've tried fetching the "last one step" from the conversation upon the subscription firing - but it seems to be killing our cache alltogether and leaves it with just one entry.