Merge "Only invalidate reads of derivedStateOf() if the result changes." into androidx-main
diff --git a/compose/runtime/runtime/api/1.0.0-beta08.txt b/compose/runtime/runtime/api/1.0.0-beta08.txt
index 9535938..665a5a5 100644
--- a/compose/runtime/runtime/api/1.0.0-beta08.txt
+++ b/compose/runtime/runtime/api/1.0.0-beta08.txt
@@ -291,6 +291,9 @@
     method public void invalidate();
   }
 
+  public final class RecomposeScopeImplKt {
+  }
+
   public final class Recomposer extends androidx.compose.runtime.CompositionContext {
     ctor public Recomposer(kotlin.coroutines.CoroutineContext effectCoroutineContext);
     method public androidx.compose.runtime.RecomposerInfo asRecomposerInfo();
diff --git a/compose/runtime/runtime/api/current.txt b/compose/runtime/runtime/api/current.txt
index 9535938..665a5a5 100644
--- a/compose/runtime/runtime/api/current.txt
+++ b/compose/runtime/runtime/api/current.txt
@@ -291,6 +291,9 @@
     method public void invalidate();
   }
 
+  public final class RecomposeScopeImplKt {
+  }
+
   public final class Recomposer extends androidx.compose.runtime.CompositionContext {
     ctor public Recomposer(kotlin.coroutines.CoroutineContext effectCoroutineContext);
     method public androidx.compose.runtime.RecomposerInfo asRecomposerInfo();
diff --git a/compose/runtime/runtime/api/public_plus_experimental_1.0.0-beta08.txt b/compose/runtime/runtime/api/public_plus_experimental_1.0.0-beta08.txt
index c535808..2c12ec0 100644
--- a/compose/runtime/runtime/api/public_plus_experimental_1.0.0-beta08.txt
+++ b/compose/runtime/runtime/api/public_plus_experimental_1.0.0-beta08.txt
@@ -308,6 +308,9 @@
     method public void invalidate();
   }
 
+  public final class RecomposeScopeImplKt {
+  }
+
   public final class Recomposer extends androidx.compose.runtime.CompositionContext {
     ctor public Recomposer(kotlin.coroutines.CoroutineContext effectCoroutineContext);
     method public androidx.compose.runtime.RecomposerInfo asRecomposerInfo();
diff --git a/compose/runtime/runtime/api/public_plus_experimental_current.txt b/compose/runtime/runtime/api/public_plus_experimental_current.txt
index c535808..2c12ec0 100644
--- a/compose/runtime/runtime/api/public_plus_experimental_current.txt
+++ b/compose/runtime/runtime/api/public_plus_experimental_current.txt
@@ -308,6 +308,9 @@
     method public void invalidate();
   }
 
+  public final class RecomposeScopeImplKt {
+  }
+
   public final class Recomposer extends androidx.compose.runtime.CompositionContext {
     ctor public Recomposer(kotlin.coroutines.CoroutineContext effectCoroutineContext);
     method public androidx.compose.runtime.RecomposerInfo asRecomposerInfo();
diff --git a/compose/runtime/runtime/api/restricted_1.0.0-beta08.txt b/compose/runtime/runtime/api/restricted_1.0.0-beta08.txt
index a82f0a0..d2a86ac 100644
--- a/compose/runtime/runtime/api/restricted_1.0.0-beta08.txt
+++ b/compose/runtime/runtime/api/restricted_1.0.0-beta08.txt
@@ -317,6 +317,9 @@
     method public void invalidate();
   }
 
+  public final class RecomposeScopeImplKt {
+  }
+
   public final class Recomposer extends androidx.compose.runtime.CompositionContext {
     ctor public Recomposer(kotlin.coroutines.CoroutineContext effectCoroutineContext);
     method public androidx.compose.runtime.RecomposerInfo asRecomposerInfo();
diff --git a/compose/runtime/runtime/api/restricted_current.txt b/compose/runtime/runtime/api/restricted_current.txt
index a82f0a0..d2a86ac 100644
--- a/compose/runtime/runtime/api/restricted_current.txt
+++ b/compose/runtime/runtime/api/restricted_current.txt
@@ -317,6 +317,9 @@
     method public void invalidate();
   }
 
+  public final class RecomposeScopeImplKt {
+  }
+
   public final class Recomposer extends androidx.compose.runtime.CompositionContext {
     ctor public Recomposer(kotlin.coroutines.CoroutineContext effectCoroutineContext);
     method public androidx.compose.runtime.RecomposerInfo asRecomposerInfo();
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
index 76af048..4a23c5c 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
@@ -19,6 +19,7 @@
 )
 package androidx.compose.runtime
 
+import androidx.compose.runtime.collection.IdentityArrayMap
 import androidx.compose.runtime.collection.IdentityArraySet
 import androidx.compose.runtime.snapshots.currentSnapshot
 import androidx.compose.runtime.snapshots.fastForEach
@@ -202,9 +203,26 @@
 }
 
 private class Invalidation(
+    /**
+     * The recompose scope being invalidate
+     */
     val scope: RecomposeScopeImpl,
-    var location: Int
-)
+
+    /**
+     * The index of the group in the slot table being invalidated.
+     */
+    val location: Int,
+
+    /**
+     * The instances invalidating the scope. If this is `null` or empty then the scope is
+     * unconditionally invalid. If it contains instances it is only invalid if at least on of the
+     * instances is changed. This is used to track `DerivedState<*>` changes and only treat the
+     * scope as invalid if the instance has changed.
+     */
+    var instances: IdentityArraySet<Any>?
+) {
+    fun isInvalid(): Boolean = scope.isInvalidFor(instances)
+}
 
 /**
  * Internal compose compiler plugin API that is used to update the function the composer will
@@ -1028,7 +1046,6 @@
     private var collectParameterInformation = false
     private var nodeExpected = false
     private val invalidations: MutableList<Invalidation> = mutableListOf()
-    internal var pendingInvalidScopes = false
     private val entersStack = IntStack()
     private var parentProvider: CompositionLocalMap = persistentHashMapOf()
     private val providerUpdates = HashMap<Int, CompositionLocalMap>()
@@ -1300,7 +1317,7 @@
     /**
      * End the current group.
      */
-    internal fun endGroup() = end(isNode = false)
+    private fun endGroup() = end(isNode = false)
 
     @OptIn(InternalComposeApi::class)
     private fun skipGroup() {
@@ -2110,35 +2127,44 @@
 
             invalidations.removeLocation(location)
 
-            recomposed = true
+            if (firstInRange.isInvalid()) {
+                recomposed = true
 
-            reader.reposition(location)
-            val newGroup = reader.currentGroup
-            // Record the changes to the applier location
-            recordUpsAndDowns(oldGroup, newGroup, parent)
-            oldGroup = newGroup
+                reader.reposition(location)
+                val newGroup = reader.currentGroup
+                // Record the changes to the applier location
+                recordUpsAndDowns(oldGroup, newGroup, parent)
+                oldGroup = newGroup
 
-            // Calculate the node index (the distance index in the node this groups nodes are
-            // located in the parent node).
-            nodeIndex = nodeIndexOf(
-                location,
-                newGroup,
-                parent,
-                recomposeIndex
-            )
+                // Calculate the node index (the distance index in the node this groups nodes are
+                // located in the parent node).
+                nodeIndex = nodeIndexOf(
+                    location,
+                    newGroup,
+                    parent,
+                    recomposeIndex
+                )
 
-            // Calculate the compound hash code (a semi-unique code for every group in the
-            // composition used to restore saved state).
-            compoundKeyHash = compoundKeyOf(
-                reader.parent(newGroup),
-                parent,
-                recomposeCompoundKey
-            )
+                // Calculate the compound hash code (a semi-unique code for every group in the
+                // composition used to restore saved state).
+                compoundKeyHash = compoundKeyOf(
+                    reader.parent(newGroup),
+                    parent,
+                    recomposeCompoundKey
+                )
 
-            firstInRange.scope.compose(this)
+                firstInRange.scope.compose(this)
 
-            // Restore the parent of the reader to the previous parent
-            reader.restoreParent(parent)
+                // Restore the parent of the reader to the previous parent
+                reader.restoreParent(parent)
+            } else {
+                // If the invalidation is not used restore the reads that were removed when the
+                // the invalidation was recorded. This happens, for example, when on of a derived
+                // state's dependencies changed but the derived state itself was not changed.
+                invalidateStack.push(firstInRange.scope)
+                firstInRange.scope.rereadTrackedInstances()
+                invalidateStack.pop()
+            }
 
             // Using slots.current here ensures composition always walks forward even if a component
             // before the current composition is invalidated when performing this composition. Any
@@ -2343,13 +2369,13 @@
             } ?: it else it
         }
 
-    internal fun tryImminentInvalidation(scope: RecomposeScopeImpl): Boolean {
+    internal fun tryImminentInvalidation(scope: RecomposeScopeImpl, instance: Any?): Boolean {
         val anchor = scope.anchor ?: return false
         val location = anchor.toIndexFor(slotTable)
         if (isComposing && location >= reader.currentGroup) {
             // if we are invalidating a scope that is going to be traversed during this
             // composition.
-            invalidations.insertIfMissing(location, scope)
+            invalidations.insertIfMissing(location, scope, instance)
             return true
         }
         return false
@@ -2482,7 +2508,7 @@
      * [content].
      */
     internal fun composeContent(
-        invalidationsRequested: IdentityArraySet<RecomposeScopeImpl>,
+        invalidationsRequested: IdentityArrayMap<RecomposeScopeImpl, IdentityArraySet<Any>?>,
         content: @Composable () -> Unit
     ) {
         check(changes.isEmpty()) { "Expected applyChanges() to have been called" }
@@ -2502,7 +2528,9 @@
      * Synchronously recompose all invalidated groups. This collects the changes which must be
      * applied by [ControlledComposition.applyChanges] to have an effect.
      */
-    internal fun recompose(invalidationsRequested: IdentityArraySet<RecomposeScopeImpl>): Boolean {
+    internal fun recompose(
+        invalidationsRequested: IdentityArrayMap<RecomposeScopeImpl, IdentityArraySet<Any>?>
+    ): Boolean {
         check(changes.isEmpty()) { "Expected applyChanges() to have been called" }
         if (invalidationsRequested.isNotEmpty()) {
             doCompose(invalidationsRequested, null)
@@ -2512,15 +2540,15 @@
     }
 
     private fun doCompose(
-        invalidationsRequested: IdentityArraySet<RecomposeScopeImpl>,
+        invalidationsRequested: IdentityArrayMap<RecomposeScopeImpl, IdentityArraySet<Any>?>,
         content: (@Composable () -> Unit)?
     ) {
         check(!isComposing) { "Reentrant composition is not supported" }
         trace("Compose:recompose") {
             snapshot = currentSnapshot()
-            invalidationsRequested.forEach { scope ->
+            invalidationsRequested.forEach { scope, set ->
                 val location = scope.anchor?.location ?: return
-                invalidations.add(Invalidation(scope, location))
+                invalidations.add(Invalidation(scope, location, set))
             }
             invalidations.sortBy { it.location }
             nodeIndex = 0
@@ -2528,12 +2556,23 @@
             isComposing = true
             try {
                 startRoot()
-                if (content != null) {
-                    startGroup(invocationKey, invocation)
-                    invokeComposable(this, content)
-                    endGroup()
-                } else {
-                    skipCurrentGroup()
+                // Ignore reads of derivedStatOf recalculations
+                observeDerivedStateRecalculations(
+                    start = {
+                        childrenComposing++
+                    },
+                    done = {
+                        childrenComposing--
+                    },
+                ) {
+                    if (content != null) {
+                        startGroup(invocationKey, invocation)
+
+                        invokeComposable(this, content)
+                        endGroup()
+                    } else {
+                        skipCurrentGroup()
+                    }
                 }
                 endRoot()
                 complete = true
@@ -3223,10 +3262,29 @@
     return -(low + 1) // key not found
 }
 
-private fun MutableList<Invalidation>.insertIfMissing(location: Int, scope: RecomposeScopeImpl) {
+private fun MutableList<Invalidation>.insertIfMissing(
+    location: Int,
+    scope: RecomposeScopeImpl,
+    instance: Any?
+) {
     val index = findLocation(location)
     if (index < 0) {
-        add(-(index + 1), Invalidation(scope, location))
+        add(
+            -(index + 1),
+            Invalidation(
+                scope,
+                location,
+                instance?.let { i ->
+                    IdentityArraySet<Any>().also { it.add(i) }
+                }
+            )
+        )
+    } else {
+        if (instance == null) {
+            get(index).instances = null
+        } else {
+            get(index).instances?.add(instance)
+        }
     }
 }
 
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
index 22fb422..ba87d25 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
@@ -17,9 +17,10 @@
 @file:OptIn(InternalComposeApi::class)
 package androidx.compose.runtime
 
+import androidx.compose.runtime.collection.IdentityArrayMap
+import androidx.compose.runtime.collection.IdentityArraySet
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
-import androidx.compose.runtime.collection.IdentityArraySet
 import androidx.compose.runtime.collection.IdentityScopeMap
 import androidx.compose.runtime.snapshots.fastForEach
 
@@ -333,6 +334,11 @@
     private val observations = IdentityScopeMap<RecomposeScopeImpl>()
 
     /**
+     * A map of object read during derived states to the corresponding derived state.
+     */
+    private val derivedStates = IdentityScopeMap<DerivedState<*>>()
+
+    /**
      * A list of changes calculated by [Composer] to be applied to the [Applier] and the
      * [SlotTable] to reflect the result of composition. This is a list of lambdas that need to
      * be invoked in order to produce the desired effects.
@@ -349,11 +355,13 @@
     private val observationsProcessed = IdentityScopeMap<RecomposeScopeImpl>()
 
     /**
-     * The set of the invalid [RecomposeScope]s. If this set is non-empty the current state of
+     * A map of the invalid [RecomposeScope]s. If this map is non-empty the current state of
      * the composition does not reflect the current state of the objects it observes and should
-     * be recomposed by calling [recompose].
+     * be recomposed by calling [recompose]. Tbe value is a map of values that invalidated the
+     * scope. The scope is checked with these instances to ensure the value has changed. This is
+     * used to only invalidate the scope if a [derivedStateOf] object changes.
      */
-    private var invalidations = IdentityArraySet<RecomposeScopeImpl>()
+    private var invalidations = IdentityArrayMap<RecomposeScopeImpl, IdentityArraySet<Any>?>()
 
     /**
      * As [RecomposeScope]s are removed the corresponding entries in the observations set must be
@@ -521,7 +529,7 @@
 
     override fun observesAnyOf(values: Set<Any>): Boolean {
         for (value in values) {
-            if (value in observations) return true
+            if (value in observations || value in derivedStates) return true
         }
         return false
     }
@@ -530,22 +538,29 @@
 
     private fun addPendingInvalidationsLocked(values: Set<Any>) {
         var invalidated: HashSet<RecomposeScopeImpl>? = null
+
+        fun invalidate(value: Any) {
+            observations.forEachScopeOf(value) { scope ->
+                if (
+                    !observationsProcessed.remove(value, scope) &&
+                    scope.invalidateForResult(value) != InvalidationResult.IGNORED
+                ) {
+                    val set = invalidated
+                        ?: HashSet<RecomposeScopeImpl>().also {
+                            invalidated = it
+                        }
+                    set.add(scope)
+                }
+            }
+        }
+
         for (value in values) {
             if (value is RecomposeScopeImpl) {
-                value.invalidateForResult()
+                value.invalidateForResult(null)
             } else {
-                observations.forEachScopeOf(value) { scope ->
-                    if (!observationsProcessed.remove(value, scope) &&
-                        scope.invalidateForResult() != InvalidationResult.IGNORED
-                    ) {
-                        (
-                            invalidated ?: (
-                                HashSet<RecomposeScopeImpl>().also {
-                                    invalidated = it
-                                }
-                                )
-                            ).add(scope)
-                    }
+                invalidate(value)
+                derivedStates.forEachScopeOf(value) {
+                    invalidate(it)
                 }
             }
         }
@@ -560,20 +575,39 @@
             composer.currentRecomposeScope?.let {
                 it.used = true
                 observations.add(value, it)
+
+                // Record derived state dependency mapping
+                if (value is DerivedState<*>) {
+                    value.dependencies.forEach { dependency ->
+                        derivedStates.add(dependency, value)
+                    }
+                }
+
                 it.recordRead(value)
             }
         }
     }
 
-    override fun recordWriteOf(value: Any) = synchronized(lock) {
+    private fun invalidateScopeOfLocked(value: Any) {
+        // Invalidate any recompose scopes that read this value.
         observations.forEachScopeOf(value) { scope ->
-            if (scope.invalidateForResult() == InvalidationResult.IMMINENT) {
+            if (scope.invalidateForResult(value) == InvalidationResult.IMMINENT) {
                 // If we process this during recordWriteOf, ignore it when recording modifications
                 observationsProcessed.add(value, scope)
             }
         }
     }
 
+    override fun recordWriteOf(value: Any) = synchronized(lock) {
+        invalidateScopeOfLocked(value)
+
+        // If writing to dependency of a derived value and the value is changed, invalidate the
+        // scopes that read the derived value.
+        derivedStates.forEachScopeOf(value) {
+            invalidateScopeOfLocked(it)
+        }
+    }
+
     override fun recompose(): Boolean = synchronized(lock) {
         drainPendingModificationsForCompositionLocked()
         composer.recompose(takeInvalidations()).also { shouldDrain ->
@@ -608,6 +642,7 @@
                 if (pendingInvalidScopes) {
                     pendingInvalidScopes = false
                     observations.removeValueIf { scope -> !scope.valid }
+                    derivedStates.removeValueIf { derivedValue -> derivedValue !in observations }
                 }
             } finally {
                 manager.dispatchAbandons()
@@ -631,7 +666,7 @@
         }
     }
 
-    fun invalidate(scope: RecomposeScopeImpl): InvalidationResult {
+    fun invalidate(scope: RecomposeScopeImpl, instance: Any?): InvalidationResult {
         if (scope.defaultsInScope) {
             scope.defaultsInvalid = true
         }
@@ -641,11 +676,18 @@
         val location = anchor.toIndexFor(slotTable)
         if (location < 0)
             return InvalidationResult.IGNORED // The scope was removed from the composition
-        if (isComposing && composer.tryImminentInvalidation(scope)) {
+        if (isComposing && composer.tryImminentInvalidation(scope, instance)) {
             // The invalidation was redirected to the composer.
             return InvalidationResult.IMMINENT
         }
-        invalidations.add(scope)
+
+        // invalidations[scope] containing an explicit null means it was invalidated
+        // unconditionally.
+        if (instance == null) {
+            invalidations[scope] = null
+        } else {
+            invalidations.addValue(scope, instance)
+        }
 
         parent.invalidate(this)
         return if (isComposing) InvalidationResult.DEFERRED else InvalidationResult.SCHEDULED
@@ -656,12 +698,14 @@
     }
 
     /**
-     * This takes ownership of the invalidations. Invalidations
+     * This takes ownership of the current invalidations and sets up a new array map to hold the
+     * new invalidations.
      */
-    private fun takeInvalidations(): IdentityArraySet<RecomposeScopeImpl> =
-        invalidations.also {
-            invalidations = IdentityArraySet()
-        }
+    private fun takeInvalidations(): IdentityArrayMap<RecomposeScopeImpl, IdentityArraySet<Any>?> {
+        val invalidations = invalidations
+        this.invalidations = IdentityArrayMap()
+        return invalidations
+    }
 
     /**
      * Helper for [verifyConsistent] to ensure the anchor match there respective invalidation
@@ -795,3 +839,14 @@
  */
 @TestOnly
 fun simulateHotReload(context: Any) = HotReloader.simulateHotReload(context)
+
+private fun <K : Any, V : Any> IdentityArrayMap<K, IdentityArraySet<V>?>.addValue(
+    key: K,
+    value: V
+) {
+    if (key in this) {
+        this[key]?.add(value)
+    } else {
+        this[key] = IdentityArraySet<V>().also { it.add(value) }
+    }
+}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt
index 703e383..97c7b8d 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/RecomposeScopeImpl.kt
@@ -17,6 +17,8 @@
 package androidx.compose.runtime
 
 import androidx.compose.runtime.collection.IdentityArrayIntMap
+import androidx.compose.runtime.collection.IdentityArrayMap
+import androidx.compose.runtime.collection.IdentityArraySet
 
 /**
  * Represents a recomposable scope or section of the composition hierarchy. Can be used to
@@ -31,6 +33,13 @@
     fun invalidate()
 }
 
+private const val UsedFlag = 0x01
+private const val DefaultsInScopeFlag = 0x02
+private const val DefaultsInvalidFlag = 0x04
+private const val RequiresRecomposeFlag = 0x08
+private const val SkippedFlag = 0x10
+private const val RereadingFlag = 0x20
+
 /**
  * A RecomposeScope is created for a region of the composition that can be recomposed independently
  * of the rest of the composition. The composer will position the slot table to the location
@@ -40,6 +49,9 @@
 internal class RecomposeScopeImpl(
     var composition: CompositionImpl?
 ) : ScopeUpdateScope, RecomposeScope {
+
+    private var flags: Int = 0
+
     /**
      * An anchor to the location in the slot table that start the group associated with this
      * recompose scope.
@@ -58,7 +70,15 @@
      * This is used as the result of [Composer.endRestartGroup] and indicates whether the lambda
      * that is stored in [block] will be used.
      */
-    var used = false
+    var used: Boolean
+        get() = flags and UsedFlag != 0
+        set(value) {
+            if (value) {
+                flags = flags or UsedFlag
+            } else {
+                flags = flags and UsedFlag.inv()
+            }
+        }
 
     /**
      * Set to true when the there are function default calculations in the scope. These are
@@ -66,20 +86,44 @@
      * change the this scope needs to be recomposed but the default values can be skipped if they
      * where not invalidated.
      */
-    var defaultsInScope = false
+    var defaultsInScope: Boolean
+        get() = flags and DefaultsInScopeFlag != 0
+        set(value) {
+            if (value) {
+                flags = flags or DefaultsInScopeFlag
+            } else {
+                flags = flags and DefaultsInScopeFlag.inv()
+            }
+        }
 
     /**
      * Tracks whether any of the calculations in the default values were changed. See
      * [defaultsInScope] for details.
      */
-    var defaultsInvalid = false
+    var defaultsInvalid: Boolean
+        get() = flags and DefaultsInvalidFlag != 0
+        set(value) {
+            if (value) {
+                flags = flags or DefaultsInvalidFlag
+            } else {
+                flags = flags and DefaultsInvalidFlag.inv()
+            }
+        }
 
     /**
      * Tracks whether the scope was invalidated directly but was recomposed because the caller
      * was recomposed. This ensures that a scope invalidated directly will recompose even if its
      * parameters are the same as the previous recomposition.
      */
-    var requiresRecompose = false
+    var requiresRecompose: Boolean
+        get() = flags and RequiresRecomposeFlag != 0
+        set(value) {
+            if (value) {
+                flags = flags or RequiresRecomposeFlag
+            } else {
+                flags = flags and RequiresRecomposeFlag.inv()
+            }
+        }
 
     /**
      * The lambda to call to restart the scopes composition.
@@ -100,8 +144,8 @@
      * Invalidate the group which will cause [composition] to request this scope be recomposed,
      * and an [InvalidationResult] will be returned.
      */
-    fun invalidateForResult(): InvalidationResult =
-        composition?.invalidate(this) ?: InvalidationResult.IGNORED
+    fun invalidateForResult(value: Any?): InvalidationResult =
+        composition?.invalidate(this, value) ?: InvalidationResult.IGNORED
 
     /**
      * Invalidate the group which will cause [composition] to request this scope be recomposed.
@@ -110,7 +154,7 @@
      * invalidate on the composer.
      */
     override fun invalidate() {
-        composition?.invalidate(this)
+        composition?.invalidate(this, null)
     }
 
     /**
@@ -121,12 +165,29 @@
 
     private var currentToken = 0
     private var trackedInstances: IdentityArrayIntMap? = null
+    private var trackedDependencies: IdentityArrayMap<DerivedState<*>, Any?>? = null
+    private var rereading: Boolean
+        get() = flags and RereadingFlag != 0
+        set(value) {
+            if (value) {
+                flags = flags or RereadingFlag
+            } else {
+                flags = flags and RereadingFlag.inv()
+            }
+        }
 
     /**
      * Indicates whether the scope was skipped (e.g. [scopeSkipped] was called.
      */
-    internal var skipped = false
-        private set
+    internal var skipped: Boolean
+        get() = flags and SkippedFlag != 0
+        private set(value) {
+            if (value) {
+                flags = flags or SkippedFlag
+            } else {
+                flags = flags and SkippedFlag.inv()
+            }
+        }
 
     /**
      * Called when composition start composing into this scope. The [token] is a value that is
@@ -141,12 +202,56 @@
     fun scopeSkipped() {
         skipped = true
     }
+
     /**
      * Track instances that were read in scope.
      */
     fun recordRead(instance: Any) {
+        if (rereading) return
         (trackedInstances ?: IdentityArrayIntMap().also { trackedInstances = it })
             .add(instance, currentToken)
+        if (instance is DerivedState<*>) {
+            val tracked = trackedDependencies ?: IdentityArrayMap<DerivedState<*>, Any?>().also {
+                trackedDependencies = it
+            }
+            tracked[instance] = instance.currentValue
+        }
+    }
+
+    /**
+     * Determine if the scope should be considered invalid.
+     *
+     * @param instances The set of objects reported as invalidating this scope.
+     */
+    fun isInvalidFor(instances: IdentityArraySet<Any>?): Boolean {
+        // If a non-empty instances exists and contains only derived state objects with their
+        // default values, then the scope should not be considered invalid. Otherwise the scope
+        // should if it was invalidated by any other kind of instance.
+        if (instances == null) return true
+        val trackedDependencies = trackedDependencies ?: return true
+        if (
+            instances.isNotEmpty() &&
+            instances.all { instance ->
+                instance is DerivedState<*> && trackedDependencies[instance] == instance.value
+            }
+        )
+            return false
+        return true
+    }
+
+    fun rereadTrackedInstances() {
+        composition?.let { composition ->
+            trackedInstances?.let { trackedInstances ->
+                rereading = true
+                try {
+                    trackedInstances.forEach { value, _ ->
+                        composition.recordReadOf(value)
+                    }
+                } finally {
+                    rereading = false
+                }
+            }
+        }
     }
 
     /**
@@ -159,7 +264,7 @@
             // If any value previous observed was not read in this current composition
             // schedule the value to be removed from the observe scope and removed from the
             // observations tracked by the composition.
-            // [used] is false if the scope was skipped. If the scope was skipped we should
+            // [skipped] is true if the scope was skipped. If the scope was skipped we should
             // leave the observations unmodified.
             if (
                 !skipped && instances.any { _, instanceToken -> instanceToken != token }
@@ -170,8 +275,17 @@
                 ) {
                     instances.removeValueIf { instance, instanceToken ->
                         (instanceToken != token).also { remove ->
-                            if (remove)
+                            if (remove) {
                                 composition.removeObservation(instance, this)
+                                (instance as? DerivedState<*>)?.let {
+                                    trackedDependencies?.let { dependencies ->
+                                        dependencies.remove(it)
+                                        if (dependencies.size == 0) {
+                                            trackedDependencies = null
+                                        }
+                                    }
+                                }
+                            }
                         }
                     }
                     if (instances.size == 0) trackedInstances = null
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt
index 316355e..dfee58f 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SnapshotState.kt
@@ -17,6 +17,8 @@
 @file:OptIn(ExperimentalTypeInference::class)
 package androidx.compose.runtime
 
+import androidx.compose.runtime.external.kotlinx.collections.immutable.PersistentList
+import androidx.compose.runtime.external.kotlinx.collections.immutable.persistentListOf
 import androidx.compose.runtime.snapshots.MutableSnapshot
 import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.runtime.snapshots.SnapshotMutableState
@@ -24,12 +26,12 @@
 import androidx.compose.runtime.snapshots.SnapshotStateMap
 import androidx.compose.runtime.snapshots.StateObject
 import androidx.compose.runtime.snapshots.StateRecord
+import androidx.compose.runtime.snapshots.fastForEach
 import androidx.compose.runtime.snapshots.newWritableRecord
 import androidx.compose.runtime.snapshots.overwritable
 import androidx.compose.runtime.snapshots.readable
 import androidx.compose.runtime.snapshots.sync
 import androidx.compose.runtime.snapshots.withCurrent
-import androidx.compose.runtime.snapshots.writable
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.Flow
@@ -349,7 +351,31 @@
 fun <K, V> Iterable<Pair<K, V>>.toMutableStateMap() =
     SnapshotStateMap<K, V>().also { it.putAll(this.toMap()) }
 
-private class DerivedSnapshotState<T>(private val calculation: () -> T) : StateObject, State<T> {
+/**
+ * A [State] that is derived from one or more other states.
+ *
+ * @see derivedStateOf
+ */
+internal interface DerivedState<T> : State<T> {
+    /**
+     * The value of the derived state retrieved without triggering a notification to read observers.
+     */
+    val currentValue: T
+
+    /**
+     * A list of the dependencies used to produce [value] or [currentValue].
+     *
+     * The [dependencies] list can be used to determine when a [StateObject] appears in the apply
+     * observer set, if the state could affect value of this derived state.
+     */
+    val dependencies: Set<StateObject>
+}
+
+private typealias DerivedStateObservers = Pair<(DerivedState<*>) -> Unit, (DerivedState<*>) -> Unit>
+private val derivedStateObservers = SnapshotThreadLocal<PersistentList<DerivedStateObservers>>()
+private class DerivedSnapshotState<T>(
+    private val calculation: () -> T
+) : StateObject, DerivedState<T> {
     private var first: ResultRecord<T> = ResultRecord()
     private class ResultRecord<T> : StateRecord() {
         var dependencies: HashSet<StateObject>? = null
@@ -366,46 +392,58 @@
 
         override fun create(): StateRecord = ResultRecord<T>()
 
-        fun isValid(snapshot: Snapshot): Boolean =
-            result != null && resultHash == readableHash(snapshot)
+        fun isValid(derivedState: DerivedState<*>, snapshot: Snapshot): Boolean =
+            result != null && resultHash == readableHash(derivedState, snapshot)
 
-        fun readableHash(snapshot: Snapshot): Int {
+        fun readableHash(derivedState: DerivedState<*>, snapshot: Snapshot): Int {
             var hash = 7
             val dependencies = sync { dependencies }
-            if (dependencies != null)
-                for (stateObject in dependencies) {
-                    val record = stateObject.firstStateRecord.readable(stateObject, snapshot)
-                    hash = 31 * hash + identityHashCode(record)
-                    hash = 31 * hash + record.snapshotId
+            if (dependencies != null) {
+                notifyObservers(derivedState) {
+                    for (stateObject in dependencies) {
+                        // Find the first record without triggering an observer read.
+                        val record = stateObject.firstStateRecord.readable(stateObject, snapshot)
+                        hash = 31 * hash + identityHashCode(record)
+                        hash = 31 * hash + record.snapshotId
+                    }
                 }
+            }
             return hash
         }
     }
 
-    private fun value(snapshot: Snapshot, calculation: () -> T): T {
-        val readable = first.readable(this, snapshot)
-        if (readable.isValid(snapshot)) {
+    private fun currentRecord(
+        readable: ResultRecord<T>,
+        snapshot: Snapshot,
+        calculation: () -> T
+    ): ResultRecord<T> {
+        if (readable.isValid(this, snapshot)) {
             @Suppress("UNCHECKED_CAST")
-            return readable.result as T
+            return readable
         }
         val newDependencies = HashSet<StateObject>()
-        val result = Snapshot.observe(
-            {
-                if (it is StateObject) newDependencies.add(it)
-            },
-            null, calculation
-        )
-
-        sync {
-            val writable = first.newWritableRecord(this, snapshot)
-            writable.dependencies = newDependencies
-            writable.resultHash = writable.readableHash(snapshot)
-            writable.result = result
+        val result = notifyObservers(this) {
+            Snapshot.observe(
+                {
+                    if (it === this)
+                        error("A derived state cannot calculation cannot read itself")
+                    if (it is StateObject) newDependencies.add(it)
+                },
+                null, calculation
+            )
         }
 
-        snapshot.notifyObjectsInitialized()
+        val written = sync {
+            val writeSnapshot = Snapshot.current
+            val writable = first.newWritableRecord(this, writeSnapshot)
+            writable.dependencies = newDependencies
+            writable.resultHash = writable.readableHash(this, writeSnapshot)
+            writable.result = result
+            writable
+        }
+        Snapshot.notifyObjectsInitialized()
 
-        return result
+        return written
     }
 
     override val firstStateRecord: StateRecord get() = first
@@ -415,7 +453,36 @@
         first = value as ResultRecord<T>
     }
 
-    override val value: T get() = value(Snapshot.current, calculation)
+    override val value: T get() {
+        // Unlike most state objects, the record list of a derived state can change during a read
+        // because reading updates the cache. To account for this, instead of calling readable,
+        // which sends the read notification, the read observer is notfied directly and current
+        // value is used instead which doesn't notify. This allow the read observer to read the
+        // value and only update the cache once.
+        Snapshot.current.readObserver?.invoke(this)
+        return currentValue
+    }
+
+    override val currentValue: T
+        get() = first.withCurrent {
+            @Suppress("UNCHECKED_CAST")
+            currentRecord(it, Snapshot.current, calculation).result as T
+        }
+
+    override val dependencies: Set<StateObject>
+        get() = first.withCurrent {
+            currentRecord(it, Snapshot.current, calculation).dependencies ?: emptySet()
+        }
+}
+
+private inline fun <R> notifyObservers(derivedState: DerivedState<*>, block: () -> R): R {
+    val observers = derivedStateObservers.get() ?: persistentListOf()
+    observers.fastForEach { (start, _) -> start(derivedState) }
+    return try {
+        block()
+    } finally {
+        observers.fastForEach { (_, done) -> done(derivedState) }
+    }
 }
 
 /**
@@ -433,6 +500,33 @@
 fun <T> derivedStateOf(calculation: () -> T): State<T> = DerivedSnapshotState(calculation)
 
 /**
+ * Observe the recalculations performed by any derived state that is recalculated during the
+ * execution of [block]. [start] is called before a calculation starts and [done] is called
+ * after the started calculation is complete.
+ *
+ * @param start a lambda called before every calculation of a derived state is in [block].
+ * @param done a lambda that is called after the state passed to [start] is recalculated.
+ * @param block the block of code to observe.
+ */
+internal fun <R> observeDerivedStateRecalculations(
+    start: (derivedState: State<*>) -> Unit,
+    done: (derivedState: State<*>) -> Unit,
+    block: () -> R
+) {
+    val previous = derivedStateObservers.get()
+    try {
+        derivedStateObservers.set(
+            (derivedStateObservers.get() ?: persistentListOf()).add(
+                start to done
+            )
+        )
+        block()
+    } finally {
+        derivedStateObservers.set(previous)
+    }
+}
+
+/**
  * Receiver scope for use with [produceState].
  */
 interface ProduceStateScope<T> : MutableState<T>, CoroutineScope {
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMap.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMap.kt
index 420e107..bbea0f2 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMap.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMap.kt
@@ -152,6 +152,12 @@
         return false
     }
 
+    inline fun forEach(block: (Any, Int) -> Unit) {
+        for (i in 0 until size) {
+            block(keys[i] as Any, values[i])
+        }
+    }
+
     /**
      * Returns the index of [key] in the set or the negative index - 1 of the location where
      * it would have been if it had been in the set.
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayMap.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayMap.kt
new file mode 100644
index 0000000..74e3053
--- /dev/null
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/collection/IdentityArrayMap.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.collection
+
+import androidx.compose.runtime.identityHashCode
+
+internal class IdentityArrayMap<Key : Any, Value : Any?>(capacity: Int = 16) {
+    internal var keys = arrayOfNulls<Any?>(capacity)
+    internal var values = arrayOfNulls<Any?>(capacity)
+    internal var size = 0
+
+    fun isEmpty() = size == 0
+    fun isNotEmpty() = size > 0
+
+    operator fun contains(key: Key): Boolean = find(key) >= 0
+
+    operator fun get(key: Key): Value? {
+        val index = find(key)
+        @Suppress("UNCHECKED_CAST")
+        return if (index >= 0) values[index] as Value else null
+    }
+
+    operator fun set(key: Key, value: Value) {
+        val index = find(key)
+        if (index >= 0) {
+            values[index] = value
+        } else {
+            val insertIndex = -(index + 1)
+            val resize = size == keys.size
+            val destKeys = if (resize) {
+                arrayOfNulls(size * 2)
+            } else keys
+            keys.copyInto(
+                destination = destKeys,
+                destinationOffset = insertIndex + 1,
+                startIndex = insertIndex,
+                endIndex = size
+            )
+            if (resize) {
+                keys.copyInto(
+                    destination = destKeys,
+                    endIndex = insertIndex
+                )
+            }
+            destKeys[insertIndex] = key
+            keys = destKeys
+            val destValues = if (resize) {
+                arrayOfNulls(size * 2)
+            } else values
+            values.copyInto(
+                destination = destValues,
+                destinationOffset = insertIndex + 1,
+                startIndex = insertIndex,
+                endIndex = size
+            )
+            if (resize) {
+                values.copyInto(
+                    destination = destValues,
+                    endIndex = insertIndex
+                )
+            }
+            destValues[insertIndex] = value
+            values = destValues
+            size++
+        }
+    }
+
+    fun remove(key: Key): Boolean {
+        val index = find(key)
+        if (index >= 0) {
+            val size = size
+            val keys = keys
+            val values = values
+            keys.copyInto(
+                destination = keys,
+                destinationOffset = index,
+                startIndex = index + 1,
+                endIndex = size
+            )
+            values.copyInto(
+                destination = values,
+                destinationOffset = index,
+                startIndex = index + 1,
+                endIndex = size
+            )
+            keys[size] = null
+            values[size] = null
+            this.size = size - 1
+            return true
+        }
+        return false
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    inline fun removeValueIf(block: (value: Value) -> Boolean) {
+        var current = 0
+        for (index in 0 until size) {
+            val value = values[index] as Value
+            if (!block(value)) {
+                if (current != index) {
+                    keys[current] = keys[index]
+                    values[current] = value
+                }
+                current++
+            }
+        }
+        if (size > current) {
+            for (index in current until size) {
+                keys[index] = null
+                values[index] = null
+            }
+            size = current
+        }
+    }
+
+    inline fun forEach(block: (key: Key, value: Value) -> Unit) {
+        for (index in 0 until size) {
+            @Suppress("UNCHECKED_CAST")
+            block(keys[index] as Key, values[index] as Value)
+        }
+    }
+
+    /**
+     * Returns the index into [keys] of the found [key], or the negative index - 1 of the
+     * position in which it would be if it were found.
+     */
+    private fun find(key: Any?): Int {
+        val keyIdentity = identityHashCode(key)
+        var low = 0
+        var high = size - 1
+
+        while (low <= high) {
+            val mid = (low + high).ushr(1)
+            val midKey = keys[mid]
+            val midKeyHash = identityHashCode(midKey)
+            val comparison = midKeyHash - keyIdentity
+            when {
+                comparison < 0 -> low = mid + 1
+                comparison > 0 -> high = mid - 1
+                key === midKey -> return mid
+                else -> return findExactIndex(mid, key, keyIdentity)
+            }
+        }
+        return -(low + 1)
+    }
+
+    /**
+     * When multiple keys share the same [identityHashCode], then we must find the specific
+     * index of the target item. This method assumes that [midIndex] has already been checked
+     * for an exact match for [key], but will look at nearby values to find the exact item index.
+     * If no match is found, the negative index - 1 of the position in which it would be will
+     * be returned, which is always after the last key with the same [identityHashCode].
+     */
+    private fun findExactIndex(midIndex: Int, key: Any?, keyHash: Int): Int {
+        // hunt down first
+        for (i in midIndex - 1 downTo 0) {
+            val k = keys[i]
+            if (k === key) {
+                return i
+            }
+            if (identityHashCode(k) != keyHash) {
+                break // we've gone too far
+            }
+        }
+
+        for (i in midIndex + 1 until size) {
+            val k = keys[i]
+            if (k === key) {
+                return i
+            }
+            if (identityHashCode(k) != keyHash) {
+                // We've gone too far. We should insert here.
+                return -(i + 1)
+            }
+        }
+
+        // We should insert at the end
+        return -(size + 1)
+    }
+}
\ No newline at end of file
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
index 6b0ecf7..1e0658eb 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
@@ -678,6 +678,7 @@
     }
 
     override fun notifyObjectsInitialized() {
+        if (applied || disposed) return
         advance()
     }
 
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionAndDerivedStateTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionAndDerivedStateTests.kt
new file mode 100644
index 0000000..cc3b024
--- /dev/null
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionAndDerivedStateTests.kt
@@ -0,0 +1,337 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import androidx.compose.runtime.mock.Text
+import androidx.compose.runtime.mock.compositionTest
+import androidx.compose.runtime.mock.expectChanges
+import androidx.compose.runtime.mock.expectNoChanges
+import androidx.compose.runtime.mock.revalidate
+import androidx.compose.runtime.mock.validate
+import androidx.compose.runtime.snapshots.Snapshot
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Tests the interaction between [derivedStateOf] and composition.
+ */
+@Stable
+class CompositionAndDerivedStateTests {
+
+    @Test
+    fun derivedStateOfChangesInvalidate() = compositionTest {
+        var a by mutableStateOf(31)
+        var b by mutableStateOf(10)
+        val answer by derivedStateOf { a + b }
+
+        compose {
+            Text("The answer is $answer")
+        }
+
+        validate {
+            Text("The answer is ${a + b}")
+        }
+
+        a++
+        expectChanges()
+
+        b++
+        expectChanges()
+
+        revalidate()
+    }
+
+    @Test
+    fun onlyInvalidatesIfResultIsDifferent() = compositionTest {
+        var a by mutableStateOf(32)
+        var b by mutableStateOf(10)
+        val answer by derivedStateOf { a + b }
+
+        compose {
+            Text("The answer is $answer")
+        }
+
+        validate {
+            Text("The answer is ${a + b}")
+        }
+
+        // A snapshot is necessary here otherwise the ui thread might see one changed but not
+        // the other. A snapshot ensures that both modifications will be seen together.
+        Snapshot.withMutableSnapshot {
+            a += 1
+            b -= 1
+        }
+
+        expectNoChanges()
+        revalidate()
+
+        a += 1
+
+        // Change just one should reflect a change.
+        expectChanges()
+        revalidate()
+
+        b -= 1
+
+        // Change just one should reflect a change.
+        expectChanges()
+        revalidate()
+
+        Snapshot.withMutableSnapshot {
+            a += 1
+            b -= 1
+        }
+
+        // Again, the change should not cause an invalidate.
+        expectNoChanges()
+        revalidate()
+    }
+
+    @Test
+    fun onlyEvaluateDerivedStatesThatAreLive() = compositionTest {
+        var a by mutableStateOf(11)
+
+        val useNone = 0x00
+        val useD = 0x01
+        val useE = 0x02
+        val useF = 0x04
+
+        var use by mutableStateOf(useD)
+
+        fun useToString(use: Int): String {
+            var result = ""
+            if (use and useD != 0) {
+                result = "useD"
+            }
+            if (use and useE != 0) {
+                if (result.isNotEmpty()) result += ", "
+                result += "useE"
+            }
+            if (use and useF != 0) {
+                if (result.isNotEmpty()) result += ", "
+                result += "useF"
+            }
+            return result
+        }
+
+        var dCalculated = 0
+        val d = "d" to derivedStateOf {
+            dCalculated++
+            a
+        }
+
+        var eCalculated = 0
+        val e = "e" to derivedStateOf {
+            eCalculated++
+            a + 100
+        }
+
+        var fCalculated = 0
+        val f = "f" to derivedStateOf {
+            fCalculated++
+            a + 1000
+        }
+
+        var dExpected = 0
+        var eExpected = 0
+        var fExpected = 0
+
+        fun expect(modified: Int, previous: Int = -1) {
+            if (modified and useD == useD) dExpected++
+            if (modified and useE == useE) eExpected++
+            if (modified and useF == useF) fExpected++
+
+            val additionalInfo = if (previous >= 0) {
+                " switching from ${useToString(previous)} to ${useToString(modified)}"
+            } else ""
+            assertEquals(dExpected, dCalculated, "d calculated an unexpected amount$additionalInfo")
+            assertEquals(eExpected, eCalculated, "e calculated an unexpected amount$additionalInfo")
+            assertEquals(fExpected, fCalculated, "f calculated an unexpected amount$additionalInfo")
+        }
+
+        // Nothing should be calculated yet.
+        expect(useNone)
+
+        compose {
+            if (use and useD == useD) {
+                Display(d)
+            }
+            if (use and useE == useE) {
+                Display(e)
+            }
+            if (use and useF == useF) {
+                Display(f)
+            }
+            if ((use and (useD or useE)) == useD or useE) {
+                Display(d, e)
+            }
+            if ((use and (useD or useF)) == useD or useF) {
+                Display(d, f)
+            }
+            if ((use and (useE or useF)) == useE or useF) {
+                Display(e, f)
+            }
+            if ((use and (useD or useE or useF)) == useD or useE or useF) {
+                Display(d, e, f)
+            }
+        }
+
+        validate {
+            if (use and useD != 0) {
+                Text("d = $a")
+            }
+            if (use and useE != 0) {
+                Text("e = ${a + 100}")
+            }
+            if (use and useF != 0) {
+                Text("f = ${a + 1000}")
+            }
+            if ((use and (useD or useE)) == useD or useE) {
+                Text("d = $a")
+                Text("e = ${a + 100}")
+            }
+            if ((use and (useD or useF)) == useD or useF) {
+                Text("d = $a")
+                Text("f = ${a + 1000}")
+            }
+            if ((use and (useE or useF)) == useE or useF) {
+                Text("e = ${a + 100}")
+                Text("f = ${a + 1000}")
+            }
+            if ((use and (useD or useE or useF)) == useD or useE or useF) {
+                Text("d = $a")
+                Text("e = ${a + 100}")
+                Text("f = ${a + 1000}")
+            }
+        }
+
+        expect(useD)
+
+        // Modify A
+        a++
+        expectChanges()
+        revalidate()
+        expect(useD)
+
+        fun switchTo(newUse: Int) {
+            val previous = use
+            use = newUse
+            a++
+            expectChanges()
+            revalidate()
+            expect(newUse, previous)
+        }
+
+        switchTo(useD or useE)
+        switchTo(useD or useF)
+
+        val states = listOf(
+            useE,
+            useF,
+            useD or useE,
+            useD or useF,
+            useD or useE or useF,
+            useE or useF,
+            useNone
+        )
+        for (newUse in states) {
+            switchTo(newUse)
+        }
+    }
+
+    @Test
+    fun ensureCalculateIsNotCalledTooSoon() = compositionTest {
+        var a by mutableStateOf(11)
+        var dCalculated = 0
+        var dChanged = false
+        val d = "d" to derivedStateOf {
+            dCalculated++
+            a + 10
+        }
+
+        compose {
+            Text("a = $a")
+            val oldDCalculated = dCalculated
+            Display(d)
+            dChanged = oldDCalculated != dCalculated
+        }
+
+        validate {
+            Text("a = $a")
+            Text("d = ${a + 10}")
+        }
+
+        assertTrue(dChanged, "Expected d to recalculate")
+
+        a++
+        expectChanges()
+        revalidate()
+        assertTrue(dChanged, "Expected d to recalculate")
+    }
+
+    @Test
+    fun writingToADerviedStateDependencyTriggersAForwardInvalidate() = compositionTest {
+        var a by mutableStateOf(12)
+        var b by mutableStateOf(30)
+        val d = derivedStateOf { a + b }
+        compose {
+            DisplayIndirect("d", d)
+            var c by remember { mutableStateOf(0) }
+            c = a + b
+            val e = remember { derivedStateOf { a + b + c } }
+            DisplayIndirect("e", e)
+        }
+
+        validate {
+            Text("d = ${a + b}")
+            Text("e = ${a + b + a + b}")
+        }
+
+        a++
+        expectChanges()
+        revalidate()
+
+        b--
+        expectChanges()
+        revalidate()
+
+        Snapshot.withMutableSnapshot {
+            a += 1
+            b -= 1
+        }
+        advance()
+        revalidate()
+    }
+}
+
+@Composable
+fun DisplayItem(name: String, state: State<Int>) {
+    Text("$name = ${state.value}")
+}
+
+@Composable
+fun DisplayIndirect(name: String, state: State<Int>) {
+    DisplayItem(name, state)
+}
+
+@Composable
+fun Display(vararg names: Pair<String, State<Int>>) {
+    for ((name, state) in names) {
+        DisplayIndirect(name, state)
+    }
+}
\ No newline at end of file
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMapTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMapTests.kt
index 03a3e88..34ba73a 100644
--- a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMapTests.kt
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/collection/IdentityArrayIntMapTests.kt
@@ -95,11 +95,23 @@
     @Test
     fun anyFindsCorrectValue() {
         val map = IdentityArrayIntMap()
-        val keys = Array<Any>(100) { Any() }
+        val keys = Array(100) { Any() }
         for (i in keys.indices) {
             map.add(keys[i], i)
         }
         assertTrue(map.any { _, value -> value == 20 })
         assertFalse(map.any { _, value -> value > 100 })
     }
+
+    @Test
+    fun canForEach() {
+        val map = IdentityArrayIntMap()
+        val keys = Array(100) { Any() }
+        for (i in keys.indices) {
+            map.add(keys[i], i)
+        }
+        map.forEach { key, value ->
+            assertEquals(keys.indexOf(key), value)
+        }
+    }
 }
\ No newline at end of file
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/collection/IdentityArrayMapTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/collection/IdentityArrayMapTests.kt
new file mode 100644
index 0000000..b4f9bec
--- /dev/null
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/collection/IdentityArrayMapTests.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.collection
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+private class Key(val value: Int)
+
+class IdentityArrayMapTests {
+    private val keys = Array(100) { Key(it) }
+
+    @Test
+    fun canCreateEmptyMap() {
+        val map = IdentityArrayMap<Key, Any>()
+        assertTrue(map.isEmpty(), "map is not empty")
+    }
+
+    @Test
+    fun canSetAndGetValues() {
+        val map = IdentityArrayMap<Key, String>()
+        map[keys[1]] = "One"
+        map[keys[2]] = "Two"
+        assertEquals("One", map[keys[1]], "map key 1")
+        assertEquals("Two", map[keys[2]], "map key 2")
+        assertEquals(null, map[keys[3]], "map key 3")
+    }
+
+    @Test
+    fun canSetAndGetManyValues() {
+        val map = IdentityArrayMap<Key, String>()
+        repeat(keys.size) {
+            map[keys[it]] = it.toString()
+        }
+        repeat(keys.size) {
+            assertEquals(it.toString(), map[keys[it]], "map key $it")
+        }
+    }
+
+    @Test
+    fun canRemoveValues() {
+        val map = IdentityArrayMap<Key, Int>()
+        repeat(keys.size) {
+            map[keys[it]] = it
+        }
+        map.removeValueIf { value -> value % 2 == 0 }
+        assertEquals(keys.size / 2, map.size)
+        for (i in 1 until keys.size step 2) {
+            assertEquals(i, map[keys[i]], "map key $i")
+        }
+        for (i in 0 until keys.size step 2) {
+            assertEquals(null, map[keys[i]], "map key $i")
+        }
+        map.removeValueIf { true }
+        assertEquals(0, map.size, "map is not empty after removing everything")
+    }
+
+    @Test
+    fun canForEachKeysAndValues() {
+        val map = IdentityArrayMap<Key, String>()
+        repeat(100) {
+            map[keys[it]] = it.toString()
+        }
+        assertEquals(100, map.size)
+        var count = 0
+        map.forEach { key, value ->
+            assertEquals(key.value.toString(), value, "map key ${key.value}")
+            count++
+        }
+        assertEquals(map.size, count, "forEach didn't loop the expected number of times")
+    }
+
+    @Test
+    fun canRemoveItems() {
+        val map = IdentityArrayMap<Key, String>()
+        repeat(100) {
+            map[keys[it]] = it.toString()
+        }
+
+        repeat(100) {
+            assertEquals(100 - it, map.size)
+            val removed = map.remove(keys[it])
+            assertTrue(removed, "Expected to remove key $it")
+            if (it > 0) {
+                assertFalse(
+                    map.remove(keys[it - 1]),
+                    "Expected item ${it - 1} to already be removed"
+                )
+            }
+        }
+    }
+}
\ No newline at end of file