Debouncing TextField bindings makes last input char lost #981
-
Hello! I have a problem with debouncing bindings. I perform save operation on every input to a TextField. When raw it works as intended. But to optimize writes to disk a bit I created a custom Effect based on Effect.fireAndForget and added a debounce to id. And here the problem starts. Some times (50%?) I don't get last input saved. For example when I input "1234" to a TextField it saves "123". If I delete whole field pressing backspace it saves "1" instead "". Let's see some code. View looks so: SomeHelperView(
title: "Some Data",
binding: viewStore.binding(\.$someData.data.value),
keyboardType: .numberPad)
struct SomeHelperView: View {
var title: String
var binding: Binding<String>
var keyboardType: UIKeyboardType
var body: some View {
VStack(alignment: .leading) {
Text(title).font(.headline)
TextField(title, text: binding)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
.keyboardType(keyboardType)
}
}
} Store like this: struct SomeState: Equatable {
@BindableState var someData = SomeManagedData.loadOrCreate(.someIdentifier)
} Action: enum SomeAction: BindableAction {
case binding(BindingAction<SomeState>)
} And Reducer: static let someReducer = Reducer<SomeState SomeAction, SomeEnvironment> { state, action, environment in
switch action {
case .binding(\.$someData.data.value):
return .saveManagedData(state.someData, scheduler: environment.mainQueue)
case .binding:
return .none
}
}.binding() And most important, my custom effect: extension Effect {
static func saveManagedData<T: ManageableData>(_ data: ManagedData<T>,
scheduler: AnySchedulerOf<DispatchQueue>,
dueTime: DispatchQueue.SchedulerTimeType.Stride = 0.3) -> Effect {
return .fireAndForget {
data.save()
DDLogDebug("[TCA] Saving managed data with id: \(data.id), data: \(data.data)")
}.debounce(id: data.id, for: dueTime, scheduler: scheduler)
}
}
Funny thing is that when I add logging to the Effect it starts to work as expected. Like logging (it's CocoaLumberjack) forced some state synchronization, don't know for sure, I'm riffing here. Any insight will be appreciated! |
Beta Was this translation helpful? Give feedback.
Replies: 4 comments 4 replies
-
I have a feeling that's the root of the issue, or at best is hiding what's going on. Is is possible to refactor your effect to use a In my experience with reference types, it is safer to keep them in the It is possible to work with reference types in If the uncontrolled reference type is not responsible there, and when the incomplete data is saved, does the |
Beta Was this translation helpful? Give feedback.
-
Not being able to store reference type in State would be unfortunate... My In above setup outside world cannot touch anything in
Nothing besides State, binding and reducer doesn't touch this data. I'm 100% sure of that. Also nothing on my side doesn't reinitialize State/Store or any other TCA component.
Yes. In UI everything is perfect. I see the problem only after I dismiss the screen and load it again effectively recreating Store, State and loading all the data from ground up.
Yes... and then some. Thanks for the Here's log: received action:
TCA.Action.DevOptions.binding(
BindingAction.set(
WritableKeyPath<DevOptions, BindableState<String>>,
"1"
)
)
(No state changes)
received action:
TCA.Action.DevOptions.binding(
BindingAction.set(
WritableKeyPath<DevOptions, BindableState<String>>,
"11"
)
)
(No state changes)
received action:
TCA.Action.DevOptions.binding(
BindingAction.set(
WritableKeyPath<DevOptions, BindableState<String>>,
"1"
)
)
(No state changes)
Binding<String> action tried to update multiple times per frame.
Binding<String> action tried to update multiple times per frame.
received action:
TCA.Action.DevOptions.close
(No state changes) |
Beta Was this translation helpful? Give feedback.
-
In case having a reference type in @propertyWrapper
public struct ReferenceState<State>: Equatable where State: AnyObject {
struct Token: Hashable {
let sec: Int
let nsec: Int
init() {
var uptime = timespec()
guard clock_gettime(CLOCK_MONOTONIC_RAW, &uptime) == 0 else {
fatalError("Unable to retrieve uptime")
}
self.sec = uptime.tv_sec
self.nsec = uptime.tv_nsec
}
}
private var didChangeToken: Token
public var wrappedValue: State
public init(wrappedValue: State) {
self.wrappedValue = wrappedValue
self.didChangeToken = Token()
}
public var projectedValue: Self {
get { self }
set { self = newValue }
}
public mutating func didChange() {
self.didChangeToken = Token()
}
public static func == (lhs: ReferenceState<State>, rhs: ReferenceState<State>) -> Bool {
lhs.wrappedValue === rhs.wrappedValue && lhs.didChangeToken == rhs.didChangeToken
}
} You can simply call If class SomeClass {
var string = ""
var count = 0
}
struct State {
@ReferenceState var object = SomeClass()
}
// In the reducer, when reducing an action:
[…]
state.object.string = "Hello!"
state.object.count += 1
state.$object.didChange()
return .none
[…] NB: The monotonically increasing |
Beta Was this translation helpful? Give feedback.
-
Thank you @tgrapperon for your input. I created a struct wrapper on my data which copies its id and value and based on that is able to save it when it's necessary. It works well. To be honest I didn't expect such problem will arrive. Personally I would consider this as a framework bug. I understand your point about risk of values being modified by outside world but as long as this is not happening I would expect everything to work the same. I have to learn a lot about TCA and SwiftUI and how all this works at low level. Will remember about this limitation. Thanks |
Beta Was this translation helpful? Give feedback.
In case having a reference type in
State
is unavoidable, this property wrapper may help standardizing things: