Skip to content
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

Debounce GraphQLQueryWatcher responses #334

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 117 additions & 4 deletions Tests/ApolloTests/Cache/WatchQueryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1255,7 +1255,7 @@ class WatchQueryTests: XCTestCase, CacheDependentTesting {
}
}

func testWatchedQueryIsUpdatedMultipleTimesIfConcurrentFetchesReturnChangedData() throws {
func testWatchedQueryIsUpdatedMultipleTimesIfConcurrentFetchesReturnChangedData_noDebounce() throws {
class HeroNameSelectionSet: MockSelectionSet {
override class var __selections: [Selection] { [
.field("hero", Hero.self)
Expand All @@ -1277,9 +1277,12 @@ class WatchQueryTests: XCTestCase, CacheDependentTesting {

let resultObserver = makeResultObserver(for: watchedQuery)

let watcher = GraphQLQueryWatcher(client: client,
query: watchedQuery,
resultHandler: resultObserver.handler)
let watcher = GraphQLQueryWatcher(
client: client,
query: watchedQuery,
debounceTimeInterval: .zero,
resultHandler: resultObserver.handler
)
addTeardownBlock { watcher.cancel() }

runActivity("Initial fetch from server") { _ in
Expand Down Expand Up @@ -1363,6 +1366,116 @@ class WatchQueryTests: XCTestCase, CacheDependentTesting {
}
}

func testWatchedQueryIsUpdatedOnceIfConcurrentFetchesReturnChangedData() throws {
class HeroNameSelectionSet: MockSelectionSet {
override class var __selections: [Selection] { [
.field("hero", Hero.self)
]}

var hero: Hero { __data["hero"] }

class Hero: MockSelectionSet {
override class var __selections: [Selection] {[
.field("__typename", String.self),
.field("name", String.self)
]}

var name: String { __data["name"] }
}
}

let watchedQuery = MockQuery<HeroNameSelectionSet>()

let resultObserver = makeResultObserver(for: watchedQuery)

let watcher = GraphQLQueryWatcher(
client: client,
query: watchedQuery,
resultHandler: resultObserver.handler
)
addTeardownBlock { watcher.cancel() }

runActivity("Initial fetch from server") { _ in
let serverRequestExpectation =
server.expect(MockQuery<HeroNameSelectionSet>.self) { request in
[
"data": [
"hero": [
"name": "R2-D2",
"__typename": "Droid"
]
]
]
}

let initialWatcherResultExpectation = resultObserver.expectation(
description: "Watcher received initial result from server"
) { result in
try XCTAssertSuccessResult(result) { graphQLResult in
XCTAssertEqual(graphQLResult.source, .server)
XCTAssertNil(graphQLResult.errors)

let data = try XCTUnwrap(graphQLResult.data)
XCTAssertEqual(data.hero.name, "R2-D2")
}
}

watcher.fetch(cachePolicy: .fetchIgnoringCacheData)

wait(for: [serverRequestExpectation, initialWatcherResultExpectation], timeout: Self.defaultWaitTimeout)
}

let numberOfFetches = 10

runActivity("Fetch same query concurrently \(numberOfFetches) times") { _ in
let serverRequestExpectation =
server.expect(MockQuery<HeroNameSelectionSet>.self) { request in
[
"data": [
"hero": [
"name": "Artoo #\(UUID())",
"__typename": "Droid"
]
]
]
}

serverRequestExpectation.expectedFulfillmentCount = numberOfFetches

let updatedWatcherResultExpectation = resultObserver.expectation(
description: "Watcher received updated result from cache"
) { result in
try XCTAssertSuccessResult(result) { graphQLResult in
XCTAssertEqual(graphQLResult.source, .cache)
XCTAssertNil(graphQLResult.errors)

let data = try XCTUnwrap(graphQLResult.data)
XCTAssertTrue(try XCTUnwrap(data.hero.name).hasPrefix("Artoo"))
}
}

updatedWatcherResultExpectation.expectedFulfillmentCount = 1

let otherFetchesCompletedExpectation = expectation(description: "Other fetches completed")
otherFetchesCompletedExpectation.expectedFulfillmentCount = numberOfFetches

DispatchQueue.concurrentPerform(iterations: numberOfFetches) { _ in
client.fetch(query: MockQuery<HeroNameSelectionSet>(),
cachePolicy: .fetchIgnoringCacheData) { [weak self] result in
otherFetchesCompletedExpectation.fulfill()

if let self = self, case .failure(let error) = result {
self.record(error)
}
}
}

wait(for: [serverRequestExpectation, otherFetchesCompletedExpectation, updatedWatcherResultExpectation], timeout: 3)

XCTAssertEqual(updatedWatcherResultExpectation.numberOfFulfillments, 1)
}
}

func testWatchedQueryDependentKeysAreUpdatedAfterDirectStoreUpdate() {
// given
struct HeroAndFriendsNamesWithIDsSelectionSet: MockMutableRootSelectionSet {
Expand Down
80 changes: 65 additions & 15 deletions apollo-ios/Sources/Apollo/GraphQLQueryWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public final class GraphQLQueryWatcher<Query: GraphQLQuery>: Cancellable, Apollo

private let contextIdentifier = UUID()
private let context: RequestContext?
private let debouncer: Debounce?

private class WeakFetchTaskContainer {
weak var cancellable: Cancellable?
Expand All @@ -38,16 +39,26 @@ public final class GraphQLQueryWatcher<Query: GraphQLQuery>: Cancellable, Apollo
/// - context: [optional] A context that is being passed through the request chain. Defaults to `nil`.
/// - callbackQueue: The queue for the result handler. Defaults to the main queue.
/// - resultHandler: The result handler to call with changes.
public init(client: ApolloClientProtocol,
query: Query,
context: RequestContext? = nil,
callbackQueue: DispatchQueue = .main,
resultHandler: @escaping GraphQLResultHandler<Query.Data>) {
public init(
client: ApolloClientProtocol,
query: Query,
context: RequestContext? = nil,
callbackQueue: DispatchQueue = .main,
debounceTimeInterval: TimeInterval = 0.5,
resultHandler: @escaping GraphQLResultHandler<Query.Data>
) {
self.client = client
self.query = query
self.resultHandler = resultHandler
self.callbackQueue = callbackQueue
self.context = context
if debounceTimeInterval > .zero {

}

self.debouncer = debounceTimeInterval > .zero
? Debounce(timeInterval: debounceTimeInterval)
: nil

client.store.subscribe(self)
}
Expand All @@ -74,11 +85,21 @@ public final class GraphQLQueryWatcher<Query: GraphQLQuery>: Cancellable, Apollo
break
}

self.resultHandler(result)
self.debounce(result: result)
}
}
}

func debounce(result: Result<GraphQLResult<Query.Data>, Error>) {
if let debouncer {
debouncer.queue { [weak self] in
self?.resultHandler(result)
}
} else {
resultHandler(result)
}
}

/// Cancel any in progress fetching operations and unsubscribe from the store.
public func cancel() {
fetching.cancellable?.cancel()
Expand All @@ -91,33 +112,33 @@ public final class GraphQLQueryWatcher<Query: GraphQLQuery>: Cancellable, Apollo
if
let incomingIdentifier = contextIdentifier,
incomingIdentifier == self.contextIdentifier {
// This is from changes to the keys made from the `fetch` method above,
// changes will be returned through that and do not need to be returned
// here as well.
return
// This is from changes to the keys made from the `fetch` method above,
// changes will be returned through that and do not need to be returned
// here as well.
return
}

guard let dependentKeys = self.dependentKeys else {
// This query has nil dependent keys, so nothing that changed will affect it.
return
}

if !dependentKeys.isDisjoint(with: changedKeys) {
// First, attempt to reload the query from the cache directly, in order not to interrupt any in-flight server-side fetch.
store.load(self.query) { [weak self] result in
guard let self = self else { return }

switch result {
case .success(let graphQLResult):
self.callbackQueue.async { [weak self] in
guard let self = self else {
return
}

self.$dependentKeys.mutate {
$0 = graphQLResult.dependentKeys
}
self.resultHandler(result)
self.debounce(result: result)
}
case .failure:
if self.fetching.cachePolicy != .returnCacheDataDontFetch {
Expand All @@ -129,3 +150,32 @@ public final class GraphQLQueryWatcher<Query: GraphQLQuery>: Cancellable, Apollo
}
}
}

private final class Debounce {
let timeInterval: TimeInterval

// using a timer avoids runloop execution on UITrackingRunLoopMode
// which will prevent UI updates during interaction
var timer: Timer?
var block: (() -> Void)?

init(timeInterval: TimeInterval) {
self.timeInterval = timeInterval
}

func cancel() {
timer?.invalidate()
block = nil
}

func queue(block: @escaping () -> Void) {
self.block = block
self.timer?.invalidate()
let timer = Timer(timeInterval: timeInterval, repeats: false, block: { [weak self] _ in
self?.block?()
self?.block = nil
})
self.timer = timer
RunLoop.current.add(timer, forMode: .default)
}
}
Loading