Skip to content
This repository was archived by the owner on Oct 14, 2024. It is now read-only.
This repository was archived by the owner on Oct 14, 2024. It is now read-only.

Local State nested updates can cause inconsistent UI #57

@fonesti

Description

@fonesti

The issue is about LocalState updates performed inside a ModellableView's update() method.

Example

This is an example Tempura UI

ViewControllerWithLocalState
           |
           |
       RootView
       |      |
       |      |
    View_A   View_B

View_A and View_B inherently depends on LocalState.

View_A has an interaction that updates the LocalState

class AppViewController: ViewControllerWithLocalState<RootView> {
  override func setupInteraction() {
    self.rootView.view_A.increment = { [unowned self] in 
      self.localState.counter += 1
    }
  }
}

As local state updates are synchronous, an interaction call inside View_A.update(oldModel:) can cause unordered nested updates and unexpected bugs 😱.

Let's say a generic state change has started an update cycle (0):
state_0 and localState_0 are used to compute rootViewModel_0, and inherently viewModel_A_0 and viewModel_B_0

  • rootView.model = rootViewModel_0 and rootView.update() begins
  • view_A.model = viewModel_A_0
  • view_A.update() begins
  • During view_A.update, the increment interaction is called, causing a nested update cycle (1)
    • state_0 and localState_1 are used to compute rootViewModel_1
    • rootView.model = rootViewModel_1 and rootView.update() begins
    • view_A.model = viewModel_A_1 and viewA.update() is executed
    • view_B.model = viewModel_A_1 and viewB.update() is executed
    • `rootView.update() ends
  • Update cycle 1 ends, update cycle 0 continues
  • viewA.update() is resumed (**)
  • viewA.update() ends
  • view_B.model = viewModel_B_0 and viewB.update() is executed (*)
  • `rootView.update() is resumed (***)
  • `rootView.update() ends
  • Update cycle 0 ends

Problems

At the end:

  1. View_B is one view model behind (*)
  2. If we use a copy of the view model inside view_A.update() (e.g. we use guard let model = self.model), a part of the old model can be applied on top of the latest model. (**)
  3. The same considerations of 2 apply to RootView (***)
  • While debugging, this behaviour is counter-intuitive in respect to the normal update cycle
  • Usually view hierarchies are more complex
  • There could be more levels of nested updates

Finding and fixing these kinds of bugs can be just difficult and really time consuming 🤕

Possible Solutions

I see two way to address this issue:

  • consider changing the local state during a model update an anti-pattern or a programming error and perform an assertion.
    • Is this kind of setup ever useful (maybe to interact with some UI related framework with observers/callbacks/delegates) or it is just the consequence of a bad design?
  • enqueue incoming LocalState to avoid nested update cycle

I'm not keen on a particular solution, both seems easy to implement.

┆Issue is synchronized with this Asana task by Unito

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions