Closed mwildehahn closed 3 years ago
Hi! 👋 I think you should be able to do this, though I don't have an example of this exact use case handy.
If your mutation returns a type that includes the viewer, and you select fields from when there you perform the mutation, those fields should get updated in the store automatically.
Example schema excerpt:
type Mutation {
login(input: LoginInput!): LoginPayload!
}
type LoginPayload {
viewer: Viewer!
}
type Viewer {
username: String
}
Example mutation:
mutation Login($input: LoginInput!) {
login(input: $input) {
viewer {
username
}
}
}
If you performed this mutation, then the username field in the viewer should update in your store, which should cause any views that are displaying that record to update.
Note that the Viewer
type name is special in Relay, so if your type isn't actually called Viewer
, then it also needs to have an id
field which will be used to match the record in the mutation response to the record already in the store.
I hope this helps! If not, feel free to share some snippets of your schema, the queries/mutations you're using, and any Swift code you think is relevant. What you want should be possible, it just might come down to specifics of how.
I noticed the Viewer
type name here: https://github.com/relay-tools/Relay.swift/blob/e770b7510b6a624803c7aa546dcbb16bfd008bd9/Sources/Relay/Record.swift#L3.
I've been going with this approach:
# From schema.graphql
# https://github.com/relayjs/relay-examples/blob/master/todo/data/schema.graphql
type Query {
viewer: User
# Fetches an object given its ID
node(
# The ID of an object
id: ID!
): Node
}
from here: https://relay.dev/docs/en/v1.7.0/quick-start-guide where viewer
just maps to a User
object.
Let me try returning the viewer
field in the mutation.
My schema right now is:
type LogoutResponse {
ok: Boolean
userId: String
}
type Mutation {
createAccount(email: String!, password: String!): UserGQL
login(email: String!, password: String!): UserGQL
logout: LogoutResponse
}
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node
viewer: UserGQL
}
type UserGQL implements Node {
email: String!
id: ID!
}
In the app, I have a root view of:
import SwiftUI
import RelaySwiftUI
import Relay
private let query = graphql("""
query RootViewQuery {
viewer {
id
...LoginView_user
}
}
""")
struct RootView: View {
@Query(RootViewQuery.self) var viewer
@Mutation(LogoutMutation.self) var logout
@State private var authenticated = false
private func handleLogin() {
authenticated = true
}
var body: some View {
if viewer.isLoading {
Text("Loading...")
} else if viewer.error != nil {
Text("Error: \(viewer.error!.localizedDescription)")
} else if viewer.data?.viewer != nil && authenticated {
Text("User ID: \(viewer.data!.viewer!.id)")
Button(action: {
logout.commit(variables: EmptyVariables(), optimisticResponse: nil, optimisticUpdater: nil, updater: nil) { result in
guard let _ = try? result.get() else { return }
authenticated = false
}
}) {
Text("Logout")
}
} else {
LoginView(onLogin: handleLogin)
}
}
}
so when unauthenticated, viewer
will be null, post login, I wanted to populate viewer
and thought this would then re-render in the logged in state. On logout, I'd like to set viewer
back to null and then have this re-render in the logged out state.
Let me update to returning the viewer
field though in those mutations.
Cool, this should work once you do that, and is generally the expected way to update data in response to a mutation. Since User
implements Node
and has an id
, the fields in the mutation response should update the same record that was fetched originally by the query, and the view will update.
By the way, I think most of the parameters you're passing to commit
have default values and can be left off. You should be able to write:
logout.commit() { result in
// ...
}
and have it work just the same.
Ah yea, I was passing those to commit so I could have a debugger in the updater.
I updated to return viewer, so you get the following payloads:
From login:
mutation {
login(email: "...", password: "...") {
viewer {
id
}
}
}
Response:
{
"data": {
"login": {
"viewer": {
"id": "OmYyZGZiOGJlLTA1MDEtNDI1My04MzY3LWVjMzVmMzEyNzgxYQ=="
}
}
}
}
Logout:
mutation {
logout {
viewer {
id
}
}
}
Response:
"data": {
"logout": {
"viewer": null
}
}
}
With a RootView of:
import SwiftUI
import RelaySwiftUI
import Relay
private let query = graphql("""
query RootViewQuery {
viewer {
id
}
}
""")
struct RootView: View {
@Query(RootViewQuery.self) var viewer
@Mutation(LogoutMutation.self) var logout
var body: some View {
if viewer.isLoading {
Text("Loading...")
} else if viewer.error != nil {
Text("Error: \(viewer.error!.localizedDescription)")
} else if let viewer = viewer.data?.viewer {
Text("User ID: \(viewer.id)")
Button(action: {
logout.commit()
}) {
Text("Logout")
}
} else {
LoginView()
}
}
}
struct RootView_Previews: PreviewProvider {
static var previews: some View {
RootView()
}
}
And a LoginView of:
import SwiftUI
import RelaySwiftUI
struct LoginView: View {
@State private var email: String = ""
@State private var password: String = ""
@Mutation(LoginMutation.self) private var login
var isButtonDisabled: Bool {
return email.isEmpty || password.isEmpty || login.isInFlight
}
var body: some View {
VStack(alignment: .center) {
Text("Login").font(.title)
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
SecureField("Password", text: $password)
Button(action: {
login.commit(variables: .init(email: email, password: password))
}) {
Text("Sign in")
}
.disabled(isButtonDisabled)
}
.padding()
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
When you first load, the login screen is correctly displayed, but after logging in, I have a debugging action printing out viewer
in the RootView
and it doesn't look like RootView
gets re-evaluated after logging in. I'm thinking this might be because of the structure of the app?
I thought since LoginView
was a child of RootView
the login mutation would trigger a re-rendering of RootView
and then no longer display the LoginView
since we have the viewer
populated.
From the debug logs I just see this though:
2020-12-28 13:13:59.813851-0800 Cubby[55094:1507422] [environment] Execution Start: RootViewQuery
2020-12-28 13:13:59.814655-0800 Cubby[55094:1507422] [query-resource] Fetch: RootViewQuery [network-only, missing, 2E764531-6387-4782-9544-7E76B13E097F]
RelaySwiftUI.Query<Cubby.RootViewQuery>.Result.loading
RelaySwiftUI.Query<Cubby.RootViewQuery>.Result.loading
2020-12-28 13:14:00.562800-0800 Cubby[55094:1507527] [] nw_protocol_get_quic_image_block_invoke dlopen libquic failed
2020-12-28 13:14:00.597770-0800 Cubby[55094:1507529] [environment] Execution Data: RootViewQuery (25 bytes)
2020-12-28 13:14:00.598554-0800 Cubby[55094:1507529] [environment] Execution Success: RootViewQuery
2020-12-28 13:14:00.599364-0800 Cubby[55094:1507422] [store] Publish: 1 records
2020-12-28 13:14:00.599570-0800 Cubby[55094:1507422] [store] Notify: RootViewQuery [1]
2020-12-28 13:14:00.601163-0800 Cubby[55094:1507422] [garbage-collection] GC Retain: RootViewQuery [0 -> 1]
2020-12-28 13:14:00.602706-0800 Cubby[55094:1507422] [query-resource] Retain: RootViewQuery [2E764531-6387-4782-9544-7E76B13E097F]
â–¿ Result
â–¿ success : Optional<Data>
â–¿ some : Data
- viewer : nil
2020-12-28 13:14:10.013333-0800 Cubby[55094:1507422] [environment] Execution Start: LoginMutation{email:"...",password:"..."}
2020-12-28 13:14:10.237704-0800 Cubby[55094:1507617] [environment] Execution Data: LoginMutation{email:"...",password:"..."} (92 bytes)
2020-12-28 13:14:10.238016-0800 Cubby[55094:1507617] [environment] Execution Success: LoginMutation{email:"...",password:"...."}
2020-12-28 13:14:10.239095-0800 Cubby[55094:1507422] [store] Publish: 3 records
2020-12-28 13:14:10.239361-0800 Cubby[55094:1507422] [store] Notify: LoginMutation{email:"...",password:"..."} [2]
After the LoginMutation
, viewer
isn't printed out in the RootView.
In the logout case, I see it re-evaluating the viewer after the LogoutMutation
but it still has a value populated for viewer:
2020-12-28 13:22:31.228897-0800 Cubby[55131:1513473] [environment] Execution Start: RootViewQuery
2020-12-28 13:22:31.229829-0800 Cubby[55131:1513473] [query-resource] Fetch: RootViewQuery [network-only, missing, 957E36DB-4525-41DE-805E-7ECF2D21278C]
RelaySwiftUI.Query<Cubby.RootViewQuery>.Result.loading
RelaySwiftUI.Query<Cubby.RootViewQuery>.Result.loading
2020-12-28 13:22:31.853991-0800 Cubby[55131:1513553] [] nw_protocol_get_quic_image_block_invoke dlopen libquic failed
2020-12-28 13:22:32.363710-0800 Cubby[55131:1513556] [environment] Execution Data: RootViewQuery (82 bytes)
2020-12-28 13:22:32.365070-0800 Cubby[55131:1513556] [environment] Execution Success: RootViewQuery
2020-12-28 13:22:32.365573-0800 Cubby[55131:1513473] [store] Publish: 2 records
2020-12-28 13:22:32.365771-0800 Cubby[55131:1513473] [store] Notify: RootViewQuery [1]
2020-12-28 13:22:32.367570-0800 Cubby[55131:1513473] [garbage-collection] GC Retain: RootViewQuery [0 -> 1]
2020-12-28 13:22:32.368485-0800 Cubby[55131:1513473] [query-resource] Retain: RootViewQuery [957E36DB-4525-41DE-805E-7ECF2D21278C]
â–¿ Result
â–¿ success : Optional<Data>
â–¿ some : Data
â–¿ viewer : Optional<UserGQL_viewer>
â–¿ some : UserGQL_viewer
- id : "OmYyZGZiOGJlLTA1MDEtNDI1My04MzY3LWVjMzVmMzEyNzgxYQ=="
2020-12-28 13:22:36.749659-0800 Cubby[55131:1513473] [environment] Execution Start: LogoutMutation
â–¿ Result
â–¿ success : Optional<Data>
â–¿ some : Data
â–¿ viewer : Optional<UserGQL_viewer>
â–¿ some : UserGQL_viewer
- id : "OmYyZGZiOGJlLTA1MDEtNDI1My04MzY3LWVjMzVmMzEyNzgxYQ=="
2020-12-28 13:22:37.151924-0800 Cubby[55131:1513561] [environment] Execution Data: LogoutMutation (36 bytes)
2020-12-28 13:22:37.152224-0800 Cubby[55131:1513561] [environment] Execution Success: LogoutMutation
2020-12-28 13:22:37.152426-0800 Cubby[55131:1513473] [store] Publish: 2 records
2020-12-28 13:22:37.152559-0800 Cubby[55131:1513473] [store] Notify: LogoutMutation [2]
â–¿ Result
â–¿ success : Optional<Data>
â–¿ some : Data
â–¿ viewer : Optional<UserGQL_viewer>
â–¿ some : UserGQL_viewer
- id : "OmYyZGZiOGJlLTA1MDEtNDI1My04MzY3LWVjMzVmMzEyNzgxYQ=="
Oh nice! Logout works correctly with:
logout.commit(optimisticResponse: nil, optimisticUpdater: nil) { (store, _) in
store.delete(dataID: DataID(viewer.id))
}
which makes sense if:
{
"data": {
"logout": {
"viewer": null
}
}
}
doesn't explicitly delete the viewer
object
If I move the login mutation to the RootView instead of the LoginView:
import SwiftUI
import RelaySwiftUI
import Relay
private let query = graphql("""
query RootViewQuery {
viewer {
id
}
}
""")
struct RootView: View {
@Query(RootViewQuery.self) var viewer
@Mutation(LogoutMutation.self) private var logout
@Mutation(LoginMutation.self) private var login
var body: some View {
if viewer.isLoading {
Text("Loading...")
} else if viewer.error != nil {
Text("Error: \(viewer.error!.localizedDescription)")
} else if let viewer = viewer.data?.viewer {
Text("User ID: \(viewer.id)")
Button(action: {
logout.commit(optimisticResponse: nil, optimisticUpdater: nil) { (store, _) in
store.delete(dataID: DataID(viewer.id))
}
}) {
Text("Logout")
}
} else {
Text("Test")
LoginView() { (email, password) in
login.commit(variables: .init(email: email, password: password))
}
}
}
}
import SwiftUI
import RelaySwiftUI
struct LoginView: View {
let onLogin: (String, String) -> Void
@State private var email: String = ""
@State private var password: String = ""
var isButtonDisabled: Bool {
return email.isEmpty || password.isEmpty
}
var body: some View {
VStack(alignment: .center) {
Text("Login").font(.title)
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
SecureField("Password", text: $password)
Button(action: {
onLogin(email, password)
}) {
Text("Sign in")
}
.disabled(isButtonDisabled)
}
.padding()
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
It re-evaluates viewer in the RootView after login which makes sense... I'm guessing @Mutation
binds the view to the result somehow?
2020-12-28 13:42:40.620590-0800 Cubby[55460:1532651] [environment] Execution Start: RootViewQuery
2020-12-28 13:42:40.621736-0800 Cubby[55460:1532651] [query-resource] Fetch: RootViewQuery [network-only, missing, DE965BC1-3AE6-4AB4-9DA0-25D4D2352198]
RelaySwiftUI.Query<Cubby.RootViewQuery>.Result.loading
RelaySwiftUI.Query<Cubby.RootViewQuery>.Result.loading
2020-12-28 13:42:41.259449-0800 Cubby[55460:1532858] [] nw_protocol_get_quic_image_block_invoke dlopen libquic failed
2020-12-28 13:42:41.760636-0800 Cubby[55460:1532858] [environment] Execution Data: RootViewQuery (82 bytes)
2020-12-28 13:42:41.761558-0800 Cubby[55460:1532858] [environment] Execution Success: RootViewQuery
2020-12-28 13:42:41.763174-0800 Cubby[55460:1532651] [store] Publish: 2 records
2020-12-28 13:42:41.763426-0800 Cubby[55460:1532651] [store] Notify: RootViewQuery [1]
2020-12-28 13:42:41.768391-0800 Cubby[55460:1532651] [garbage-collection] GC Retain: RootViewQuery [0 -> 1]
2020-12-28 13:42:41.769255-0800 Cubby[55460:1532651] [query-resource] Retain: RootViewQuery [DE965BC1-3AE6-4AB4-9DA0-25D4D2352198]
â–¿ Result
â–¿ success : Optional<Data>
â–¿ some : Data
â–¿ viewer : Optional<UserGQL_viewer>
â–¿ some : UserGQL_viewer
- id : "OmYyZGZiOGJlLTA1MDEtNDI1My04MzY3LWVjMzVmMzEyNzgxYQ=="
2020-12-28 13:42:43.950049-0800 Cubby[55460:1532651] [environment] Execution Start: LogoutMutation
â–¿ Result
â–¿ success : Optional<Data>
â–¿ some : Data
â–¿ viewer : Optional<UserGQL_viewer>
â–¿ some : UserGQL_viewer
- id : "OmYyZGZiOGJlLTA1MDEtNDI1My04MzY3LWVjMzVmMzEyNzgxYQ=="
2020-12-28 13:42:44.382034-0800 Cubby[55460:1532857] [environment] Execution Data: LogoutMutation (36 bytes)
2020-12-28 13:42:44.382302-0800 Cubby[55460:1532857] [environment] Execution Success: LogoutMutation
2020-12-28 13:42:44.382785-0800 Cubby[55460:1532651] [store] Publish: 3 records
2020-12-28 13:42:44.382932-0800 Cubby[55460:1532651] [store] Notify: LogoutMutation [2]
â–¿ Result
â–¿ success : Optional<Data>
â–¿ some : Data
- viewer : nil
2020-12-28 13:42:51.463642-0800 Cubby[55460:1532651] [environment] Execution Start: LoginMutation{email:"...",password:"..."}
â–¿ Result
â–¿ success : Optional<Data>
â–¿ some : Data
- viewer : nil
2020-12-28 13:42:51.863024-0800 Cubby[55460:1532862] [environment] Execution Data: LoginMutation{email:"...",password:"..."} (92 bytes)
2020-12-28 13:42:51.863288-0800 Cubby[55460:1532862] [environment] Execution Success: LoginMutation{email:"...",password:"..."}
2020-12-28 13:42:51.864147-0800 Cubby[55460:1532651] [store] Publish: 3 records
2020-12-28 13:42:51.864343-0800 Cubby[55460:1532651] [store] Notify: LoginMutation{email:"...",password:"..."} [3]
â–¿ Result
â–¿ success : Optional<Data>
â–¿ some : Data
- viewer : nil
But viewer
is still null 🤔 .
I tried creating the viewer in the store in the updater
function for login.commit
but got an error that the object had already been created with that ID.
Ah okay, I see what's happening here. I may have led you a bit astray.
The automatic updating of records in the store works well for updating fields of existing nodes, but it can't automatically clear or populate references to a record (unless that's also a field on another node).
Because the logged out state is that viewer
is null
, there's no existing record in the store to update. The change that you want is to set the viewer
field on the root record, but there's no automatic way for Relay to know that.
You were on the right track in thinking that you need to use an updater
function to make this happen. I think the right thing would look something like this:
login.commit(variables: .init(email: email, password: password), updater: { store, _ in
guard let viewer = store.getRootField("login")?.getLinkedRecord("viewer") else {
return
}
store.root.setLinkedRecord("viewer", viewer)
})
store.getRootField
lets you access the mutation response as a record, and from there get the new viewer record you want. Then you are able to set it as the value of the viewer
field on the root record of your local store.
Your logout updater should work as is: deleting a record is a valid way to clear existing references. But you could also use store.root["viewer"] = nil
to clear out the reference without deleting the record. If other records referred to that same user record, it would stick around and continue to be used, otherwise, it would be garbage collected. It's up to you which one makes more sense for your app's data.
This documentation page about updater functions goes into this topic a little bit, though it's possible it could use a few more examples. In fact, I may update it to include this case as another example.
I was playing around with trying to set on the store.root
but always get:
.../Views/RootView.swift:62:36: Cannot use mutating member on immutable value: 'root' is a get-only property
What's the full context of how you're accessing it? store
is an inout
parameter. If you assign it to a let
variable, it'll lose the mutating methods and properties on it. There may be other ways for that to happen too.
I set up the environment similar to this: https://github.com/relay-tools/Relay.swift/blob/main/RelayTodo/Environment.swift
The view where I'm trying to do the mutation:
import SwiftUI
import RelaySwiftUI
import Relay
import Combine
private let query = graphql("""
query RootViewQuery {
viewer {
id
...HomeView_user
}
}
""")
struct RootView: View {
@Query(RootViewQuery.self) var query
@Mutation(LogoutMutation.self) private var logout
@Mutation(LoginMutation.self) private var login
@EnvironmentObject private var context: Context
@State private var assetService: AssetService?
@State private var syncCancellable: AnyCancellable?
var body: some View {
Group {
if query.isLoading {
Text("Loading...")
} else if query.error != nil {
Text("Error: \(query.error!.localizedDescription)")
} else if let viewer = query.data?.viewer {
HomeView(user: viewer)
.onAppear {
context.viewerId = viewer.id
DispatchQueue.global(qos: .background).async {
syncCancellable = assetService?.sync()
}
}
.onDisappear {
syncCancellable?.cancel()
}
Button(action: {
logout.commit(optimisticResponse: nil, optimisticUpdater: nil) { (store, _) in
store.delete(dataID: DataID(viewer.id))
context.viewerId = nil
}
}) {
Text("Logout")
}
} else {
Text("Test")
LoginView() { (email, password) in
login.commit(variables: .init(email: email, password: password), updater: { store, _ in
guard let viewer = store.getRootField("login")?.getLinkedRecord("viewer") else { return }
store.root.setLinkedRecord("viewer", record: viewer) // <-- error here
}, completion: { result in
context.viewerId = try? result.get()?.login?.viewer?.id
})
}
}
}
.onAppear {
assetService = AssetService(context: context)
}
}
}
Along with the main app view:
import SwiftUI
import Combine
import Relay
import RelaySwiftUI
@main
struct CubbyApp: App {
let persistenceController = PersistenceManager.shared
var context = Context()
var body: some Scene {
WindowGroup {
RootView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.relayEnvironment(environment)
.environmentObject(context)
}
}
}
with environment:
import Combine
import Foundation
import Relay
private let graphqlURL = URL(string: "http://localhost:3000/api/graphql")!
let store = Store(source: DefaultRecordSource())
let environment = Environment(network: Network(), store: store)
struct RequestPayload: Encodable {
var query: String
var operationName: String
var variables: VariableData
}
class Network: Relay.Network {
func execute(request: RequestParameters, variables: VariableData, cacheConfig: CacheConfig) -> AnyPublisher<Data, Error> {
var req = URLRequest(url: graphqlURL)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpMethod = "POST"
do {
let payload = RequestPayload(query: request.text!, operationName: request.name, variables: variables)
req.httpBody = try JSONEncoder().encode(payload)
} catch {
return Fail(error: error).eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: req)
.map { $0.data }
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
}
I tried changing store
and environment
to var
but that didn't fix it. I'm very new to Swift 😆 .
Thanks for all the context you've shared! It looks like you're doing everything right here, so I took a closer look and it's actually a bug on my end that root
is not mutable. I assumed it was mutable and you just didn't have a mutable variable for store
, but nope, looks like root
is in fact get
-only.
I can fix this, but in the meantime, you can assign store.root
to its own var
to be able to set fields on it:
login.commit(variables: .init(email: email, password: password), updater: { store, _ in
guard let viewer = store.getRootField("login")?.getLinkedRecord("viewer") else { return }
var root = store.root
root.setLinkedRecord("viewer", record: viewer)
}, completion: { result in
context.viewerId = try? result.get()?.login?.viewer?.id
})
(This is a really weird interaction between mutating
methods and the fact that RecordProxy
instances are reference types)
🎉 It works!!
This is amazing, thank you!
Wonderful! Feel free to file more issues if you hit any other problems or have questions.
I'm going to leave this one open until I fix the bug you hit.
👋 Love all the work that you've done here.
I'm new to Swift so I might be thinking about this wrong, but is there a way to update the root store after a mutation? I'm building out authentication and have a
viewer
in the query along with alogin
mutation. After logging in, I'd like some way to populate the "viewer" record so the view re-renders.Am I missing some way to do that or is there a better way?
Thanks!