relay-tools / Relay.swift

Relay for GraphQL, ported to Swift and SwiftUI
https://relay-tools.github.io/Relay.swift
MIT License
65 stars 4 forks source link

Updating root store after a mutation #8

Closed mwildehahn closed 3 years ago

mwildehahn commented 3 years ago

👋 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 a login 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!

mjm commented 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.

mwildehahn commented 3 years ago

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.

mjm commented 3 years ago

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.

mwildehahn commented 3 years ago

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.

mwildehahn commented 3 years ago

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=="
mwildehahn commented 3 years ago

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

mwildehahn commented 3 years ago

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.

mjm commented 3 years ago

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.

mjm commented 3 years ago

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.

mwildehahn commented 3 years ago

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
mjm commented 3 years ago

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.

mwildehahn commented 3 years ago

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 😆 .

mjm commented 3 years ago

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.

https://github.com/relay-tools/Relay.swift/blob/856ace347caf7bd9a2dbf966ea4e2059226f83c8/Sources/Relay/RecordSourceProxy.swift#L5

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)

mwildehahn commented 3 years ago

🎉 It works!!

This is amazing, thank you!

mjm commented 3 years ago

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.