Skip to content

Commit d16c8e0

Browse files
Access ViewStore state dynamically via closure (#36)
* Access ViewStore state dynamically via closure This makes ViewStore's behavior more like that of Bindings. This is in reponse to an unusual bug we experienced while working on Subconscious. Was experiencing a mysterious crasher when factoring NavigationStack into a subview. What I discovered was that, at some point, NavigationStack seems to be attempting to mutate the array that manages that stack. I believe the fact that we pass the stack down as a value type may be causing the problem. It's obscured because the issue happens in Apple's proprietary SwiftUI code. The crash had to do with an array manipulation, and seemed likely to be related to the array that manages the view stack, or perhaps it with some sort of race condition in view rendering on the SwiftUI side. The workaround was giving NavigationStack a @State binding and replaying changes onto it. However, this made me wonder if the cause was the fact that we passed the stack by value down the the view, before creating a Binding that referenced it. After stubbing in a ViewStore with a Binding-like closure get function, the issue was resolved. So it seems that there are corner cases where SwiftUI needs to dynamically read the value via closure, not have it passed down by value. * Deprecated CursorProtocol and KeyedCursorProtocol Bring back these protocols for backwards-compat, but mark them deprecated.
1 parent 933547c commit d16c8e0

2 files changed

Lines changed: 184 additions & 16 deletions

File tree

Sources/ObservableStore/ObservableStore.swift

Lines changed: 181 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,18 @@ where Model: ModelProtocol
223223
self.subscribe(to: update.fx)
224224
}
225225

226+
/// Initialize and send an initial action to the store.
227+
/// Useful when performing actions once and only once upon creation
228+
/// of the store.
229+
public convenience init(
230+
state: Model,
231+
action: Model.Action,
232+
environment: Model.Environment
233+
) {
234+
self.init(state: state, environment: environment)
235+
self.send(action)
236+
}
237+
226238
/// Subscribe to a publisher of actions, piping them through to
227239
/// the store.
228240
///
@@ -312,45 +324,68 @@ where Model: ModelProtocol
312324
}
313325
}
314326

327+
/// Create a ViewStore, a scoped view over a store.
328+
/// ViewStore is conceptually like a SwiftUI Binding. However, instead of
329+
/// offering get/set for some source-of-truth, it offers a StoreProtocol.
330+
///
331+
/// Using ViewStore, you can create self-contained views that work with their
332+
/// own domain
315333
public struct ViewStore<ViewModel: ModelProtocol>: StoreProtocol {
334+
/// `_get` reads some source of truth dynamically, using a closure.
335+
///
336+
/// NOTE: We've found this to be important for some corner cases in
337+
/// SwiftUI components, where capturing the state by value may produce
338+
/// unexpected issues. Examples are input fields and NavigationStack,
339+
/// which both expect a Binding to a state (which dynamically reads
340+
/// the value using a closure). Using the same approach as Binding
341+
/// offers the most reliable results.
342+
private var _get: () -> ViewModel
316343
private var _send: (ViewModel.Action) -> Void
317-
public var state: ViewModel
318344

345+
/// Initialize a ViewStore from a `get` closure and a `send` closure.
346+
/// These closures read from a parent store to provide a type-erased
347+
/// view over the store that only exposes domain-specific
348+
/// model and actions.
319349
public init(
320-
state: ViewModel,
350+
get: @escaping () -> ViewModel,
321351
send: @escaping (ViewModel.Action) -> Void
322352
) {
323-
self.state = state
353+
self._get = get
324354
self._send = send
325355
}
326-
356+
357+
public var state: ViewModel {
358+
self._get()
359+
}
360+
327361
public func send(_ action: ViewModel.Action) {
328362
self._send(action)
329363
}
330364
}
331365

332366
extension ViewStore {
333-
public init<Action>(
334-
state: ViewModel,
335-
send: @escaping (Action) -> Void,
336-
tag: @escaping (ViewModel.Action) -> Action
367+
/// Initialize a ViewStore from a Store, using a `get` and `tag` closure.
368+
public init<Store: StoreProtocol>(
369+
store: Store,
370+
get: @escaping (Store.Model) -> ViewModel,
371+
tag: @escaping (ViewModel.Action) -> Store.Model.Action
337372
) {
338373
self.init(
339-
state: state,
340-
send: { action in send(tag(action)) }
374+
get: { get(store.state) },
375+
send: { action in store.send(tag(action)) }
341376
)
342377
}
343378
}
344379

345380
extension StoreProtocol {
346381
/// Create a viewStore from a StoreProtocol
347382
public func viewStore<ViewModel: ModelProtocol>(
348-
get: (Self.Model) -> ViewModel,
383+
get: @escaping (Self.Model) -> ViewModel,
349384
tag: @escaping (ViewModel.Action) -> Self.Model.Action
350385
) -> ViewStore<ViewModel> {
351386
ViewStore(
352-
state: get(self.state),
353-
send: self.send,
387+
store: self,
388+
get: get,
354389
tag: tag
355390
)
356391
}
@@ -368,6 +403,139 @@ public struct Address {
368403
}
369404
}
370405

406+
/// A cursor provides a complete description of how to map from one component
407+
/// domain to another.
408+
public protocol CursorProtocol {
409+
associatedtype Model: ModelProtocol
410+
associatedtype ViewModel: ModelProtocol
411+
412+
/// Get an inner state from an outer state
413+
static func get(state: Model) -> ViewModel
414+
415+
/// Set an inner state on an outer state, returning an outer state
416+
static func set(state: Model, inner: ViewModel) -> Model
417+
418+
/// Tag an inner action, transforming it into an outer action
419+
static func tag(_ action: ViewModel.Action) -> Model.Action
420+
}
421+
422+
extension CursorProtocol {
423+
/// Update an outer state through a cursor.
424+
/// CursorProtocol.update offers a convenient way to call child
425+
/// update functions from the parent domain, and get parent-domain
426+
/// states and actions back from it.
427+
///
428+
/// - `state` the outer state
429+
/// - `action` the inner action
430+
/// - `environment` the environment for the update function
431+
/// - Returns a new outer state
432+
@available(
433+
*,
434+
deprecated,
435+
message: "CursorProtocol is depreacated and will be removed in a future update. Use ModelProtocol.update(get:set:tag:state:action:environment:) instead."
436+
)
437+
public static func update(
438+
state: Model,
439+
action viewAction: ViewModel.Action,
440+
environment: ViewModel.Environment
441+
) -> Update<Model> {
442+
let next = ViewModel.update(
443+
state: get(state: state),
444+
action: viewAction,
445+
environment: environment
446+
)
447+
return Update(
448+
state: set(state: state, inner: next.state),
449+
fx: next.fx.map(tag).eraseToAnyPublisher(),
450+
transaction: next.transaction
451+
)
452+
}
453+
}
454+
455+
public protocol KeyedCursorProtocol {
456+
associatedtype Key
457+
associatedtype Model: ModelProtocol
458+
associatedtype ViewModel: ModelProtocol
459+
460+
/// Get an inner state from an outer state
461+
static func get(state: Model, key: Key) -> ViewModel?
462+
463+
/// Set an inner state on an outer state, returning an outer state
464+
static func set(state: Model, inner: ViewModel, key: Key) -> Model
465+
466+
/// Tag an inner action, transforming it into an outer action
467+
static func tag(action: ViewModel.Action, key: Key) -> Model.Action
468+
}
469+
470+
extension KeyedCursorProtocol {
471+
/// Update an inner state within an outer state through a keyed cursor.
472+
/// This cursor type is useful when looking up children in dynamic lists
473+
/// such as arrays or dictionaries.
474+
///
475+
/// - `state` the outer state
476+
/// - `action` the inner action
477+
/// - `environment` the environment for the update function
478+
/// - `key` a key uniquely representing this model in the parent domain
479+
/// - Returns an update for a new outer state or nil
480+
@available(
481+
*,
482+
deprecated,
483+
message: "KeyedCursorProtocol is depreacated and will be removed in a future update. Use ModelProtocol.update(get:set:tag:state:action:environment:) instead."
484+
)
485+
public static func update(
486+
state: Model,
487+
action viewAction: ViewModel.Action,
488+
environment viewEnvironment: ViewModel.Environment,
489+
key: Key
490+
) -> Update<Model>? {
491+
guard let viewModel = get(state: state, key: key) else {
492+
return nil
493+
}
494+
let next = ViewModel.update(
495+
state: viewModel,
496+
action: viewAction,
497+
environment: viewEnvironment
498+
)
499+
return Update(
500+
state: set(state: state, inner: next.state, key: key),
501+
fx: next.fx
502+
.map({ viewAction in Self.tag(action: viewAction, key: key) })
503+
.eraseToAnyPublisher(),
504+
transaction: next.transaction
505+
)
506+
}
507+
508+
/// Update an inner state within an outer state through a keyed cursor.
509+
/// This cursor type is useful when looking up children in dynamic lists
510+
/// such as arrays or dictionaries.
511+
///
512+
/// This version of update always returns an `Update`. If the child model
513+
/// cannot be found at key, then it returns an update for the same state
514+
/// (noop), effectively ignoring the action.
515+
///
516+
/// - `state` the outer state
517+
/// - `action` the inner action
518+
/// - `environment` the environment for the update function
519+
/// - `key` a key uniquely representing this model in the parent domain
520+
/// - Returns an update for a new outer state or nil
521+
public static func update(
522+
state: Model,
523+
action viewAction: ViewModel.Action,
524+
environment viewEnvironment: ViewModel.Environment,
525+
key: Key
526+
) -> Update<Model> {
527+
guard let next = update(
528+
state: state,
529+
action: viewAction,
530+
environment: viewEnvironment,
531+
key: key
532+
) else {
533+
return Update(state: state)
534+
}
535+
return next
536+
}
537+
}
538+
371539
extension Binding {
372540
/// Initialize a Binding from a store.
373541
/// - `get` reads the binding value.

Tests/ObservableStoreTests/ViewStoreTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ final class ViewStoreTests: XCTestCase {
7474
struct ParentChildCursor {
7575
static let `default` = ParentChildCursor()
7676

77-
func get(_ state: ParentModel) -> ChildModel? {
77+
func get(_ state: ParentModel) -> ChildModel {
7878
state.child
7979
}
8080

@@ -100,8 +100,8 @@ final class ViewStoreTests: XCTestCase {
100100
)
101101

102102
let viewStore = ViewStore(
103-
state: store.state.child,
104-
send: store.send,
103+
store: store,
104+
get: ParentChildCursor.default.get,
105105
tag: ParentChildCursor.default.tag
106106
)
107107

0 commit comments

Comments
 (0)