cashapp / molecule

Build a StateFlow stream using Jetpack Compose
https://cashapp.github.io/molecule/docs/1.x/
Apache License 2.0
1.78k stars 76 forks source link

Launching Molecule with Dispatchers.Main.immediate breaks state invalidations in some Compose code #465

Open benkay opened 1 week ago

benkay commented 1 week ago

The issue is demonstrated by the below code, which reproduces the issue without actually depending on Molecule. The original problem manifested as images loaded by Coil not fading in correctly - Coil's CrossfadePainter functions in much the same way as the Painter in this code.

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
    setContent {
      MaterialTheme {
        Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
          TestImage(modifier = Modifier.padding(innerPadding))
        }
      }
    }

    // Replacing the below block of code with this also causes the same issue
    // lifecycle.coroutineScope.launchMolecule(RecompositionMode.Immediate) {}

    lifecycle.coroutineScope.launch {
      var applyScheduled = false
      Snapshot.registerGlobalWriteObserver {
        if (!applyScheduled) {
          applyScheduled = true
          launch {
            applyScheduled = false
            Snapshot.sendApplyNotifications()
          }
        }
      }

      awaitCancellation()
    }
  }
}

@Composable
fun TestImage(modifier: Modifier = Modifier) {
  Image(
    painter = remember { InvalidatingPainter() },
    modifier = modifier,
    contentDescription = ""
  )
}

class InvalidatingPainter : Painter() {

  private var invalidateTick by mutableIntStateOf(0)
  private var lastStartTime: TimeSource.Monotonic.ValueTimeMark? = null
  private var duration = 1.seconds
  private var reverse = false

  override val intrinsicSize: Size = Size(200f, 200f)

  override fun DrawScope.onDraw() {
    val startTime = lastStartTime ?: TimeSource.Monotonic.markNow().also { lastStartTime = it }
    val percent = (startTime.elapsedNow() / duration).toFloat().coerceIn(0f, 1f)
    val alpha = if (reverse) 1 - percent else percent
    drawRect(Color.Blue, alpha = alpha)

    if (percent >= 1f) {
      lastStartTime = null
      reverse = !reverse
    }
    invalidateTick++
  }
}

The cause seems to be

The answer here seems to be "never use the immediate dispatcher", but it's very easy to do so without knowing at the moment. e.g. viewModelScope.launchMolecule(..) will use the immediate dispatcher unless otherwise specified.

Perhaps Molecule could detect this and do the right thing?

JakeWharton commented 1 week ago

So there's a few paths forward here, and I'll probably do all of them:

  1. We should provide the option to disable starting our own snapshot application callback. If you are in an Android app with Compose UI there's no reason to run a second snapshot applier.
  2. We should provide the ability to pass a parent Composition (which likely implies 1). If you are hosting Molecule beneath Compose UI to drive the UI, we can make our composition a child of it.
  3. We should detect this case and force dispatch. I assume this implies a CoroutineDispatcher is available as a key in the context, but if not perhaps we just hard fail?