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