luxmentis / SiestaExt

SwiftUI and Combine additions to Siesta
2 stars 0 forks source link

Does not work when resources are wiped while loading? #1

Open laptou opened 8 months ago

laptou commented 8 months ago

I have the following code in my application:

struct HomeView: View {
  // other variables omitted for brevity

  // Profile is a struct which implements Codable and Identifiable
  @State private var friends: TypedResource<[Profile]> = ApiService.main.service.resource("/friends").typed()

  private func buildFriendsList() -> some View {
    VStack(alignment: .leading, spacing: 16.0) {
      Text("your nearby friends")
        .font(Font.DesignSystem.displaySmBold)
      ResourceView(friends, statusDisplay: .standard) { friends in
        List {

          ForEach(friends) { item in
            HStack {
              VStack(alignment: .leading) {
                Text(item.fullName)
                  .font(Font.DesignSystem.textMdSemibold)
                  .listRowBackground(Color.bgSecondary)
              }
            }
            .padding(.vertical, 8.0)
          }
          .listRowBackground(Color.clear)
          .listRowInsets(EdgeInsets())
        }
        .overlay(alignment: .top) {
          if friends.isEmpty {
            ContentUnavailableView {
              Label("no friends", systemImage: "person.fill.questionmark")
            } description: {
              Text("add your friends on rdvz to invite them on adventures")
            }
          }
        }
        .listStyle(.plain)
        .scrollContentBackground(.hidden)
      }
    }
    .padding(.horizontal, 24.0)
    .padding(.vertical, 24.0)
  }
}

And my ApiService.swift looks like this:


import Foundation
import Siesta
import Siesta_Alamofire
import SiestaExt
import Alamofire
import Combine
import AlamofireNetworkActivityLogger

@MainActor
class ApiService {
  var service: Service

  var authToken: String? {
    didSet {
      DispatchQueue.main.schedule { [weak self] in
        self?.service.invalidateConfiguration()
        self?.service.wipeResources()
      }
    }
  }

  init() {

#if DEBUG
    SiestaLog.Category.enabled = .all
    NetworkActivityLogger.shared.level = .debug
    NetworkActivityLogger.shared.startLogging()
#endif

    let reqManager = Session()

    service = Service(
      baseURL: "http://localhost:4321/api",
      standardTransformers: [.json, .text],
      networking: reqManager
    )

    service.configure("**") { [weak self] in
      $0.headers["user-agent"] = "rdvz/ios"
      $0.headers["accept"] = "application/json"

      if let authToken = self?.authToken {
        $0.headers["authorization"] = "Bearer \(authToken)"
      }
    }

    Task { [weak self] in
      for await (event, session) in await supabaseClient.auth.authStateChanges {
        self?.authToken = session?.accessToken
      }
    }
  }

  static let main = ApiService()
}

When Supabase emits an event to point out that the user has logged in, this happens at the same time as the initial request to the backend for the friends list. The initial request is cancelled, but then the application doesn't send another request to get the data, it just does nothing and shows no data.

In the Xcode preview, where I am not logged in with Supabase, I can see the request hitting my localhost server, but in the Simulator, where I am logged in, no request ever reaches my server. I also tried making HomeView.friends a computed property, as well as inlining the call to .resource() into my view builder; neither of these approaches worked.

The logs from my app look like this:

Siesta:configuration  │ Added config 0 [Siesta standard JSON parsing]
Siesta:configuration  │ Added config 1 [Siesta standard text parsing]
Siesta:configuration  │ URL pattern ** compiles to regex ^http:\/\/localhost:4321\/api\/[^?]*($|\?)
Siesta:configuration  │ Added config 2 [**]
Siesta:configuration  │ Computing configuration for GET Resource(…/friends)[]
Siesta:configuration  │   ├╴Applying config 0 [Siesta standard JSON parsing]
Siesta:configuration  │   ├╴Applying config 1 [Siesta standard text parsing]
Siesta:configuration  │   ├╴Applying config 2 [**]
Siesta:configuration  │   └╴Resulting configuration 
Siesta:configuration  │       expirationTime:            30.0 sec
Siesta:configuration  │       retryTime:                 1.0 sec
Siesta:configuration  │       progressReportingInterval: 0.05 sec
Siesta:configuration  │       headers (2)
Siesta:configuration  │         user-agent: rdvz/ios
Siesta:configuration  │         accept: application/json
Siesta:configuration  │       requestDecorators: 0
Siesta:configuration  │       pipeline
Siesta:configuration  │         ║ rawData stage (no transformers)
Siesta:configuration  │         ║ decoding stage (no transformers)
Siesta:configuration  │         ║ parsing stage
Siesta:configuration  │         ╟   ⟨*/json */*+json⟩ Data → JSONConvertible  [transformErrors: true]
Siesta:configuration  │         ╟   ⟨text/*⟩ Data → String  [transformErrors: true]
Siesta:configuration  │         ║ model stage (no transformers)
Siesta:configuration  │         ║ cleanup stage (no transformers)
Siesta:network        │ Cache request for Resource(…/friends)[]
Siesta:staleness      │ Resource(…/friends)[] is not up to date: no error | no data
Siesta:network        │ Chain[Request:6000009f6080(Cache request for Resource(…/friends)[])]
Siesta:networkDetails │ Cache request for Resource(…/friends)[] already started
Siesta:staleness      │ Resource(…/friends)[L] is not up to date: no error | no data
Siesta:staleness      │ Resource(…/friends)[] is not up to date: no error | no data
Siesta:networkDetails │ Request: 
Siesta:networkDetails │     headers (2)
Siesta:networkDetails │       User-Agent: rdvz/ios
Siesta:networkDetails │       Accept: application/json
Siesta:network        │ GET http://localhost:4321/api/friends
Siesta:observers      │ Resource(…/friends)[L] sending requested event to 1 observer
Siesta:observers      │   ↳ requested → ClosureObserver(ObservableResource.swift:15)
Siesta:staleness      │ Resource(…/friends)[L] is not up to date: no error | no data
Siesta:networkDetails │ GET http://localhost:4321/api/friends already started
Siesta:configuration  │ Configurations need to be recomputed
Siesta:stateChanges   │ Resource(…/friends)[L] wiped
Siesta:network        │ Cancelled GET http://localhost:4321/api/friends
Siesta:network        │ cancel() called but request already completed: GET http://localhost:4321/api/friends
Siesta:observers      │ Resource(…/friends)[L] sending newData(wipe) event to 1 observer
Siesta:observers      │   ↳ newData(wipe) → ClosureObserver(ObservableResource.swift:15)
Siesta:configuration  │ Computing configuration for GET Resource(…/friends)[L]
Siesta:configuration  │   ├╴Applying config 0 [Siesta standard JSON parsing]
Siesta:configuration  │   ├╴Applying config 1 [Siesta standard text parsing]
Siesta:configuration  │   ├╴Applying config 2 [**]
Siesta:configuration  │   └╴Resulting configuration 
Siesta:configuration  │       expirationTime:            30.0 sec
Siesta:configuration  │       retryTime:                 1.0 sec
Siesta:configuration  │       progressReportingInterval: 0.05 sec
Siesta:configuration  │       headers (3)
Siesta:configuration  │         authorization: Bearer <JWT goes here, I removed it>
Siesta:configuration  │         user-agent: rdvz/ios
Siesta:configuration  │         accept: application/json
Siesta:configuration  │       requestDecorators: 0
Siesta:configuration  │       pipeline
Siesta:configuration  │         ║ rawData stage (no transformers)
Siesta:configuration  │         ║ decoding stage (no transformers)
Siesta:configuration  │         ║ parsing stage
Siesta:configuration  │         ╟   ⟨*/json */*+json⟩ Data → JSONConvertible  [transformErrors: true]
Siesta:configuration  │         ╟   ⟨text/*⟩ Data → String  [transformErrors: true]
Siesta:configuration  │         ║ model stage (no transformers)
Siesta:configuration  │         ║ cleanup stage (no transformers)
Siesta:staleness      │ Resource(…/friends)[L] is not up to date: no error | no data
Siesta:network        │ Cache request for Resource(…/friends)[L]
---------------------
GET 'http://localhost:4321/api/friends':
cURL:
$ curl -v \
    -X GET \
    -H "Accept-Language: en-US;q=1.0" \
    -H "Accept-Encoding: br;q=1.0, gzip;q=0.9, deflate;q=0.8" \
    -H "User-Agent: rdvz/ios" \
    -H "Accept: application/json" \
    "http://localhost:4321/api/friends"
Siesta:observers      │ Resource(…/friends)[] sending requestCancelled event to 1 observer
Siesta:observers      │   ↳ requestCancelled → ClosureObserver(ObservableResource.swift:15)
Siesta:staleness      │ Resource(…/friends)[] is not up to date: no error | no data
---------------------
[Error] GET 'http://localhost:4321/api/friends' [0.0242 s]:
Error Domain=NSURLErrorDomain Code=-999 "cancelled" UserInfo={NSErrorFailingURLStringKey=http://localhost:4321/api/friends, NSLocalizedDescription=cancelled, NSErrorFailingURLKey=http://localhost:4321/api/friends}
Siesta:network        │ Response:  explicitlyCancelled ← GET http://localhost:4321/api/friends
Siesta:networkDetails │ Raw response headers: nil
Siesta:networkDetails │ Raw response body: 0 bytes
Siesta:networkDetails │ Received response, but request was already cancelled: GET http://localhost:4321/api/friends 
Siesta:networkDetails │     New response: RequestError(userMessage: "Request explicitly cancelled.", httpStatusCode: nil, entity: nil, cause: Optional(Alamofire.AFError.explicitlyCancelled), timestamp: 732752061.707411)
laptou commented 8 months ago

Actually, it doesn't seem to be a timing issue. I tried adding a delay to the update from Supabase.

var authToken: String? {
    didSet {
      DispatchQueue.main.schedule(after: DispatchQueue.SchedulerTimeType(.now() + 20)) { [weak self] in
        self?.service.invalidateConfiguration()
        self?.service.wipeResources()
      }
    }
  }

At first, the request is made with no auth token, and the view reflects that:

Simulator Screenshot - iPhone 15 Pro - 2024-03-21 at 18 27 11

After twenty seconds, the update hits, and the view goes to its broken state:

Simulator Screenshot - iPhone 15 Pro - 2024-03-21 at 18 27 34

Logs:

Siesta:stateChanges   │ Resource(…/friends)[E] wiped
Siesta:observers      │ Resource(…/friends)[] sending newData(wipe) event to 1 observer
Siesta:observers      │   ↳ newData(wipe) → ClosureObserver(ObservableResource.swift:15)
Siesta:configuration  │ Computing configuration for GET Resource(…/friends)[]
Siesta:configuration  │   ├╴Applying config 0 [Siesta standard JSON parsing]
Siesta:configuration  │   ├╴Applying config 1 [Siesta standard text parsing]
Siesta:configuration  │   ├╴Applying config 2 [**]
Siesta:configuration  │   └╴Resulting configuration 
Siesta:configuration  │       expirationTime:            30.0 sec
Siesta:configuration  │       retryTime:                 1.0 sec
Siesta:configuration  │       progressReportingInterval: 0.05 sec
Siesta:configuration  │       headers (3)
Siesta:configuration  │         authorization: Bearer  <JWT removed>
Siesta:configuration  │         user-agent: rdvz/ios
Siesta:configuration  │         accept: application/json
Siesta:configuration  │       requestDecorators: 0
Siesta:configuration  │       pipeline
Siesta:configuration  │         ║ rawData stage (no transformers)
Siesta:configuration  │         ║ decoding stage (no transformers)
Siesta:configuration  │         ║ parsing stage
Siesta:configuration  │         ╟   ⟨*/json */*+json⟩ Data → JSONConvertible  [transformErrors: true]
Siesta:configuration  │         ╟   ⟨text/*⟩ Data → String  [transformErrors: true]
Siesta:configuration  │         ║ model stage (no transformers)
Siesta:configuration  │         ║ cleanup stage (no transformers)
Siesta:staleness      │ Resource(…/friends)[] is not up to date: no error | no data
Siesta:network        │ Cache request for Resource(…/friends)[]
luxmentis commented 8 months ago

Hi, good to see some interest in this new project. Do star the repo if you're finding it useful (as much as I hate to ask) – it helps others to see there's some level of adoption.

This is expected behaviour, and would be the same if you were using plain Siesta. You've cleared locally cached resources with wipeResources(), but you haven't told Siesta to fetch the resource again with Resource.loadIfNeeded() (or even load()).

I guess since ResourceView calls the initial loadIfNeeded for you behind the scenes, it might have set the expectation that it will automagically fetch the resource for you whenever needed, but it's not that clever (I'm not sure that it could be).

The simplest solution would be to call self?.service.resource("/friends").loadIfNeeded() in your didSet immediately after wipeResources(). A more elaborate solution might be to broadcast a theoretical authDidChange event to the rest of your app; interested parties could subscribe and take care of reloading their own resources.

Why are you seeing this?

Perhaps a more common flow is

But you have

Again, this would be the same in plain Siesta.

Let me know your thoughts / how things go.

luxmentis commented 8 months ago

By the way, no need to make your TypedResource a @State variable – a computed variable would be the thing. Or perhaps better (stylistically) a computed variable on your API class.

laptou commented 8 months ago

Thanks so much! I'm new to iOS development (I found out that Siesta existed less than 12 hours ago) so I appreciate you creating this library and explaining the issue. Once I get the chance to try your suggestion I'll let you know if it worked.

laptou commented 8 months ago

I guess since ResourceView calls the initial loadIfNeeded for you behind the scenes, it might have set the expectation that it will automagically fetch the resource for you whenever needed, but it's not that clever (I'm not sure that it could be).

Publishers in Combine have ways to measure whether they have active subscribers. The way that I imagine that it would work is that as soon as a resource is invalidated, it would be refreshed if it has any active subscribers (this is similar to how React Query works, for example). Do you think there is a reason why this would be a bad idea?

luxmentis commented 8 months ago

It's technically possible (ObservableResource watches ResourceEvents and could do this), but my first instinct is that it's an overly general solution for a specific use case. For example, wipeResources is usually called on logout too, so this would generate an unwanted request (which would presumably fail with a 401) – not the end of the world perhaps, but it doesn't feel quite right.

(Well, chances are that subscriptions would be getting cancelled around this time due to UI changes on logout, but perhaps not immediately.)

Thinking about vanilla Siesta, the same functionality could have been implemented there (wipeResources could cause a subsequent load if the resource has observers), but hasn't been.