@@ -36,11 +36,15 @@ import kotlin.collections.asSequence
3636 * Unlike [gg.essential.gui.elementa.state.v2.impl.minimal.MarkThenPullImpl], this means that sub-graphs which are
3737 * potentially affected but whose dependencies have not actually changed, will not be visited (more than once per
3838 * them actually changing; as opposed to having to re-visit every time they are potentially affected).
39- * That does mean that this implementation will in exchange potentially visit intermediate nodes which do not actually
40- * have any effects attached to them any more (hence it only being "semi lazy").
39+ * That does mean that this implementation will in exchange potentially visit intermediate nodes which, by the time
40+ * they're being visited, do not actually have any effects attached to them any more (hence it only being "semi lazy").
4141 * However, in practice, non-affected nodes usually vastly outnumber dead intermediate nodes (especially because
4242 * those are usually garbage collected together with the respective effects that used them) by one to two orders of
4343 * magnitude, making this well worth it.
44+ *
45+ * Furthermore, support has later been added to dynamically unregister such intermediate nodes which no longer have
46+ * any effects attached to them, which almost completely eliminates the above downside, at the cost of making
47+ * `getUntracked` more expensive on such nodes when called frequently, and a bit more internal bookkeeping.
4448 */
4549internal object MarkThenPushAndPullImpl : Impl {
4650 override fun <T > mutableState (value : T ): MutableState <T > {
@@ -132,6 +136,40 @@ private class Node<T>(
132136 private val dependents: Sequence <Node <* >>
133137 get() = allDependents.asSequence().filterNot { it.suspended }.mapNotNull { it.dependent }
134138
139+ /* *
140+ * Tracks how many of [allDependents] have [NodeKind.Effect] nodes attached to them (directly or transitively).
141+ * A number of 0 indicates that there are no [NodeKind.Effect] nodes observing this value in any way, so we don't
142+ * actually need to update it when one of its dependencies changes (we can defer that update until someone actually
143+ * wants the value).
144+ *
145+ * This intentionally uses [allDependents] rather than [dependents] and ignores the [Edge.suspended] flag for the
146+ * same reason that flag exists in the first place: When re-evaluating a node, we don't want to have to decrement
147+ * this counter on all its dependencies (and potentially their transitive dependencies), only to then likely have
148+ * to increment it again on most of them as the node re-subscribes to the same nodes again.
149+ * Instead we'll only update this counter when we also update the actual [allDependents] list.
150+ */
151+ private var dependentsWithEffects = if (kind == NodeKind .Effect ) 1 else 0
152+ set(value) {
153+ if (field == 0 && value > 0 ) {
154+ for (edge in allDependencies) {
155+ edge.dependency.dependentsWithEffects++
156+ }
157+ } else if (field > 0 && value == 0 ) {
158+ for (edge in allDependencies) {
159+ edge.dependency.dependentsWithEffects--
160+ }
161+ // Note: We must not yet unregister our dependency edges here!
162+ // For one, that'll likely result in a ConcurrentModificationException,
163+ // but more importantly, if we do that, then we won't know whether our value is up-to-date any more.
164+ // One of our dependencies could change, and we wouldn't know about it. So effectively we'd have
165+ // to re-compute all such effects on every call to `getUntracked`, which would be very expensive.
166+ // Instead we'll stay subscribed even when there's no downstream `effect` any more, and only
167+ // unsubscribe once we know we're Dirty.
168+ // This is handled in [markDirty].
169+ }
170+ field = value
171+ }
172+
135173 override fun Observer.get (): T {
136174 return getTracked(this @get)
137175 }
@@ -165,6 +203,10 @@ private class Node<T>(
165203 dependency.allDependents.add(edge)
166204 dependent.allDependencies.add(edge)
167205
206+ if (dependent.dependentsWithEffects > 0 ) {
207+ dependency.dependentsWithEffects++
208+ }
209+
168210 // To prevent unbounded growth, we'll clean up any stale edges whenever we add a new one
169211 // (this is really fast in when there isn't anything to do thanks to the ReferenceQueue)
170212 cleanupStaleReferences()
@@ -207,7 +249,28 @@ private class Node<T>(
207249 NodeState .Dirty , NodeState .Dead -> return // already dirty, nothing to do
208250 }
209251
210- update.queueNode(this )
252+ // The node is dirty. If any effect is observing it, it needs to be updated.
253+ if (dependentsWithEffects > 0 ) {
254+ update.queueNode(this )
255+ } else {
256+ // otherwise we can defer updating until someone actually needs its value.
257+
258+ // We can now also dispose of all our dependency edges.
259+ // The only thing our dependencies could do with those edges is try to mark us as ToBeChecked or Dirty,
260+ // but we're already Dirty, so that wouldn't change anything.
261+ // And by disposing of these edges, we reduce the amount of downstream dependents which our dependencies
262+ // have to mark on every change.
263+ // See also the `Note` comment in the implementation of [dependentsWithEffects].
264+ for (edge in allDependencies) {
265+ // We cannot remove the edge from `edge.allDependents` directly, because the caller is likely iterating
266+ // over that list right now.
267+ // We can mark it as garbage collected though, and let the existing stale edge cleanup code deal with
268+ // it automatically.
269+ edge.clear() // sets reference value to `null`, so the edge appears as stale
270+ edge.enqueue() // enqueues edge in its ReferenceQueue, so cleanupStaleReferences will do something
271+ }
272+ allDependencies.clear()
273+ }
211274
212275 state = NodeState .Dirty
213276 }
@@ -254,6 +317,9 @@ private class Node<T>(
254317 allDependencies.removeIf { edge ->
255318 if (edge.suspended) {
256319 edge.dependency.allDependents.remove(edge)
320+ if (dependentsWithEffects > 0 ) {
321+ edge.dependency.dependentsWithEffects--
322+ }
257323 true
258324 } else {
259325 false
@@ -276,6 +342,7 @@ private class Node<T>(
276342
277343 for (edge in allDependencies) {
278344 edge.dependency.allDependents.remove(edge)
345+ edge.dependency.dependentsWithEffects--
279346 }
280347 allDependencies.clear()
281348
@@ -296,7 +363,19 @@ private class Node<T>(
296363 @Suppress(" ControlFlowWithEmptyBody" )
297364 while (queue.poll() != null );
298365
299- allDependents.removeIf { it.dependent == null }
366+ var updatedEffectsCount = 0
367+ allDependents.removeIf { edge ->
368+ val dependent = edge.dependent
369+ if (dependent == null ) {
370+ true
371+ } else {
372+ if (dependent.dependentsWithEffects > 0 ) {
373+ updatedEffectsCount + = 1
374+ }
375+ false
376+ }
377+ }
378+ dependentsWithEffects = updatedEffectsCount
300379 }
301380
302381 /* *
0 commit comments