From 9cc6c51b9cff20fee8f2df645206380e66b09689 Mon Sep 17 00:00:00 2001
From: Daan Vanden Bosch <daan.v.d.bosch@gmail.com>
Date: Wed, 11 May 2022 17:07:32 +0200
Subject: [PATCH] Added mutateDeferred and made some other changes to avoid
 having to use setTimeout to change observables in response to observables
 changing. This fixes some unit tests.

---
 .../core/disposable/TrackedDisposableTests.kt |  4 +-
 .../observable/AbstractDependency.kt          |  2 +-
 .../observable/CallbackChangeObserver.kt      |  2 +-
 .../phantasmal/observable/CallbackObserver.kt |  2 +-
 .../world/phantasmal/observable/Change.kt     | 10 ---
 .../world/phantasmal/observable/Mutation.kt   | 19 +++++
 .../{ChangeManager.kt => MutationManager.kt}  | 36 +++++++-
 .../phantasmal/observable/MutationTests.kt    | 29 +++++++
 .../phantasmal/observable/cell/ChangeTests.kt |  6 +-
 .../PathAwareTabContainerController.kt        | 12 +--
 .../phantasmal/web/core/rendering/Renderer.kt | 12 +--
 .../web/core/widgets/RendererWidget.kt        | 16 ++--
 .../questEditor/models/QuestEntityModel.kt    | 14 +--
 .../web/questEditor/stores/AsmStore.kt        |  8 +-
 .../questEditor/stores/QuestEditorStore.kt    | 34 ++++----
 .../web/questEditor/undo/TextModelUndo.kt     | 11 ++-
 .../questEditor/widgets/AsmEditorWidget.kt    | 40 +++++----
 .../controllers/ViewerToolbarController.kt    |  4 +-
 .../web/viewer/stores/ViewerStore.kt          | 12 +--
 .../phantasmal/web/test/TestComponents.kt     |  5 +-
 .../phantasmal/webui/widgets/LazyLoader.kt    | 12 +--
 .../phantasmal/webui/widgets/TabContainer.kt  |  4 +-
 .../world/phantasmal/webui/widgets/Widget.kt  | 50 ++++++-----
 .../phantasmal/webui/widgets/WidgetTests.kt   | 85 +++++++++----------
 24 files changed, 246 insertions(+), 183 deletions(-)
 delete mode 100644 observable/src/commonMain/kotlin/world/phantasmal/observable/Change.kt
 create mode 100644 observable/src/commonMain/kotlin/world/phantasmal/observable/Mutation.kt
 rename observable/src/commonMain/kotlin/world/phantasmal/observable/{ChangeManager.kt => MutationManager.kt} (62%)
 create mode 100644 observable/src/commonTest/kotlin/world/phantasmal/observable/MutationTests.kt

diff --git a/core/src/commonTest/kotlin/world/phantasmal/core/disposable/TrackedDisposableTests.kt b/core/src/commonTest/kotlin/world/phantasmal/core/disposable/TrackedDisposableTests.kt
index 8ab43e03..036d4986 100644
--- a/core/src/commonTest/kotlin/world/phantasmal/core/disposable/TrackedDisposableTests.kt
+++ b/core/src/commonTest/kotlin/world/phantasmal/core/disposable/TrackedDisposableTests.kt
@@ -9,12 +9,12 @@ class TrackedDisposableTests {
     @Test
     fun is_correctly_tracked() {
         assertFails {
-            checkNoDisposableLeaks {
+            DisposableTracking.checkNoLeaks {
                 object : TrackedDisposable() {}
             }
         }
 
-        checkNoDisposableLeaks {
+        DisposableTracking.checkNoLeaks {
             val disposable = object : TrackedDisposable() {}
             disposable.dispose()
         }
diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/AbstractDependency.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/AbstractDependency.kt
index 6343ae91..2d0752e0 100644
--- a/observable/src/commonMain/kotlin/world/phantasmal/observable/AbstractDependency.kt
+++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/AbstractDependency.kt
@@ -25,7 +25,7 @@ abstract class AbstractDependency<T> : Dependency<T> {
             callsInPlace(block, InvocationKind.EXACTLY_ONCE)
         }
 
-        ChangeManager.changeDependency {
+        MutationManager.changeDependency {
             emitDependencyInvalidated()
             block()
         }
diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackChangeObserver.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackChangeObserver.kt
index 4ce26875..89fe58e2 100644
--- a/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackChangeObserver.kt
+++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackChangeObserver.kt
@@ -23,7 +23,7 @@ class CallbackChangeObserver<T, E : ChangeEvent<T>>(
     }
 
     override fun dependencyInvalidated(dependency: Dependency<*>) {
-        ChangeManager.invalidated(this)
+        MutationManager.invalidated(this)
     }
 
     override fun pull() {
diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackObserver.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackObserver.kt
index 6eab7214..5c818dbc 100644
--- a/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackObserver.kt
+++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/CallbackObserver.kt
@@ -25,7 +25,7 @@ class CallbackObserver(
     }
 
     override fun dependencyInvalidated(dependency: Dependency<*>) {
-        ChangeManager.invalidated(this)
+        MutationManager.invalidated(this)
     }
 
     override fun pull() {
diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/Change.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/Change.kt
deleted file mode 100644
index 277a6bbe..00000000
--- a/observable/src/commonMain/kotlin/world/phantasmal/observable/Change.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package world.phantasmal.observable
-
-/**
- * Defer propagation of changes to observables until the end of a code block. All changes to
- * observables in a single change set won't be propagated to their dependencies until the change set
- * is completed.
- */
-fun change(block: () -> Unit) {
-    ChangeManager.inChangeSet(block)
-}
diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/Mutation.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/Mutation.kt
new file mode 100644
index 00000000..6ebd6b6e
--- /dev/null
+++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/Mutation.kt
@@ -0,0 +1,19 @@
+package world.phantasmal.observable
+
+/**
+ * Defer propagation of changes to observables until the end of a code block. All changes to
+ * observables in a single mutation won't be propagated to their dependencies until the mutation is
+ * completed.
+ */
+inline fun mutate(block: () -> Unit) {
+    MutationManager.mutate(block)
+}
+
+/**
+ * Schedule a mutation to run right after the current mutation finishes. You can use this to change
+ * observables in an observer callback. This is usually a bad idea, but sometimes the situation
+ * where you have to change observables in response to observables changing is very hard to avoid.
+ */
+fun mutateDeferred(block: () -> Unit) {
+    MutationManager.mutateDeferred(block)
+}
diff --git a/observable/src/commonMain/kotlin/world/phantasmal/observable/ChangeManager.kt b/observable/src/commonMain/kotlin/world/phantasmal/observable/MutationManager.kt
similarity index 62%
rename from observable/src/commonMain/kotlin/world/phantasmal/observable/ChangeManager.kt
rename to observable/src/commonMain/kotlin/world/phantasmal/observable/MutationManager.kt
index 3fc0162b..1fa503d9 100644
--- a/observable/src/commonMain/kotlin/world/phantasmal/observable/ChangeManager.kt
+++ b/observable/src/commonMain/kotlin/world/phantasmal/observable/MutationManager.kt
@@ -8,17 +8,32 @@ import kotlin.contracts.contract
 //       Dependencies will need to partially apply ListChangeEvents etc. and remember which part of
 //       the event they've already applied (i.e. an index into the changes list).
 // TODO: Think about nested change sets. Initially don't allow nesting?
-object ChangeManager {
+object MutationManager {
     private val invalidatedLeaves = HashSet<LeafDependent>()
 
     /** Whether a dependency's value is changing at the moment. */
     private var dependencyChanging = false
 
-    fun inChangeSet(block: () -> Unit) {
-        // TODO: Implement inChangeSet correctly.
+    private val deferredMutations: MutableList<() -> Unit> = mutableListOf()
+    private var applyingDeferredMutations = false
+
+    inline fun mutate(block: () -> Unit) {
+        contract {
+            callsInPlace(block, EXACTLY_ONCE)
+        }
+
+        // TODO: Implement mutate correctly.
         block()
     }
 
+    fun mutateDeferred(block: () -> Unit) {
+        if (dependencyChanging) {
+            deferredMutations.add(block)
+        } else {
+            block()
+        }
+    }
+
     fun invalidated(dependent: LeafDependent) {
         invalidatedLeaves.add(dependent)
     }
@@ -51,6 +66,21 @@ object ChangeManager {
         } finally {
             dependencyChanging = false
             invalidatedLeaves.clear()
+
+            if (!applyingDeferredMutations) {
+                try {
+                    applyingDeferredMutations = true
+                    var i = 0
+
+                    while (i < deferredMutations.size) {
+                        deferredMutations[i]()
+                        i++
+                    }
+                } finally {
+                    applyingDeferredMutations = false
+                    deferredMutations.clear()
+                }
+            }
         }
     }
 }
diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/MutationTests.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/MutationTests.kt
new file mode 100644
index 00000000..81272a46
--- /dev/null
+++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/MutationTests.kt
@@ -0,0 +1,29 @@
+package world.phantasmal.observable
+
+import world.phantasmal.observable.cell.mutableCell
+import world.phantasmal.observable.test.ObservableTestSuite
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MutationTests : ObservableTestSuite {
+    @Test
+    fun can_change_observed_cell_with_mutateDeferred() = test {
+        val cell = mutableCell(0)
+        var observerCalls = 0
+
+        disposer.add(cell.observe {
+            observerCalls++
+
+            if (it < 10) {
+                mutateDeferred {
+                    cell.value++
+                }
+            }
+        })
+
+        cell.value = 1
+
+        assertEquals(10, observerCalls)
+        assertEquals(10, cell.value)
+    }
+}
diff --git a/observable/src/commonTest/kotlin/world/phantasmal/observable/cell/ChangeTests.kt b/observable/src/commonTest/kotlin/world/phantasmal/observable/cell/ChangeTests.kt
index 1f6cb8e4..29eded7c 100644
--- a/observable/src/commonTest/kotlin/world/phantasmal/observable/cell/ChangeTests.kt
+++ b/observable/src/commonTest/kotlin/world/phantasmal/observable/cell/ChangeTests.kt
@@ -1,6 +1,6 @@
 package world.phantasmal.observable.cell
 
-import world.phantasmal.observable.change
+import world.phantasmal.observable.mutate
 import world.phantasmal.observable.test.ObservableTestSuite
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -16,7 +16,7 @@ class ChangeTests : ObservableTestSuite {
         disposer.add(dependent.observeChange { dependentObservedValue = it.value })
 
         assertFails {
-            change {
+            mutate {
                 dependency.value = 11
                 throw Exception()
             }
@@ -27,7 +27,7 @@ class ChangeTests : ObservableTestSuite {
         assertEquals(22, dependent.value)
 
         // The machinery behind change is still in a valid state.
-        change {
+        mutate {
             dependency.value = 13
         }
 
diff --git a/web/src/main/kotlin/world/phantasmal/web/core/controllers/PathAwareTabContainerController.kt b/web/src/main/kotlin/world/phantasmal/web/core/controllers/PathAwareTabContainerController.kt
index 6e4d0826..acdbfd7b 100644
--- a/web/src/main/kotlin/world/phantasmal/web/core/controllers/PathAwareTabContainerController.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/core/controllers/PathAwareTabContainerController.kt
@@ -1,8 +1,8 @@
 package world.phantasmal.web.core.controllers
 
-import kotlinx.browser.window
 import world.phantasmal.observable.cell.Cell
 import world.phantasmal.observable.cell.map
+import world.phantasmal.observable.mutateDeferred
 import world.phantasmal.web.core.PwToolType
 import world.phantasmal.web.core.stores.UiStore
 import world.phantasmal.webui.controllers.Tab
@@ -38,11 +38,11 @@ open class PathAwareTabContainerController<T : PathAwareTab>(
         super.visibleChanged(visible)
 
         if (visible) {
-            // TODO: Remove this hack.
-            window.setTimeout({
-                if (disposed) return@setTimeout
-                setPathPrefix(activeTab.value, replace = true)
-            }, 0)
+            mutateDeferred {
+                if (!disposed) {
+                    setPathPrefix(activeTab.value, replace = true)
+                }
+            }
         }
     }
 
diff --git a/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt b/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt
index b8c6564a..687f022b 100644
--- a/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/core/rendering/Renderer.kt
@@ -20,19 +20,21 @@ abstract class Renderer : DisposableContainer() {
     private var animationFrameHandle: Int = 0
 
     fun startRendering() {
-        logger.trace { "${this::class.simpleName} - start rendering." }
-
         if (!rendering) {
+            logger.trace { "${this::class.simpleName} - start rendering." }
+
             rendering = true
             renderLoop()
         }
     }
 
     fun stopRendering() {
-        logger.trace { "${this::class.simpleName} - stop rendering." }
+        if (rendering) {
+            logger.trace { "${this::class.simpleName} - stop rendering." }
 
-        rendering = false
-        window.cancelAnimationFrame(animationFrameHandle)
+            rendering = false
+            window.cancelAnimationFrame(animationFrameHandle)
+        }
     }
 
     open fun setSize(width: Int, height: Int) {
diff --git a/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt b/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt
index 5e6c00b4..cb195b04 100644
--- a/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/core/widgets/RendererWidget.kt
@@ -12,14 +12,6 @@ class RendererWidget(
         div {
             className = "pw-core-renderer"
 
-            observeNow(selfOrAncestorVisible) { visible ->
-                if (visible) {
-                    renderer.startRendering()
-                } else {
-                    renderer.stopRendering()
-                }
-            }
-
             addDisposable(size.observeChange { (size) ->
                 renderer.setSize(size.width.toInt(), size.height.toInt())
             })
@@ -27,6 +19,14 @@ class RendererWidget(
             append(renderer.canvas)
         }
 
+    override fun selfAndAncestorsVisibleChanged(visible: Boolean) {
+        if (visible) {
+            renderer.startRendering()
+        } else {
+            renderer.stopRendering()
+        }
+    }
+
     companion object {
         init {
             @Suppress("CssUnusedSymbol")
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt
index fb73d030..3aaf6b69 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/models/QuestEntityModel.kt
@@ -6,7 +6,7 @@ import world.phantasmal.observable.cell.Cell
 import world.phantasmal.observable.cell.list.ListCell
 import world.phantasmal.observable.cell.list.listCell
 import world.phantasmal.observable.cell.mutableCell
-import world.phantasmal.observable.change
+import world.phantasmal.observable.mutate
 import world.phantasmal.web.core.minus
 import world.phantasmal.web.core.rendering.conversion.vec3ToEuler
 import world.phantasmal.web.core.rendering.conversion.vec3ToThree
@@ -60,7 +60,7 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
     })
 
     open fun setSectionId(sectionId: Int) {
-        change {
+        mutate {
             entity.sectionId = sectionId.toShort()
             _sectionId.value = sectionId
 
@@ -83,7 +83,7 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
             "Quest entities can't be moved across areas."
         }
 
-        change {
+        mutate {
             entity.sectionId = section.id.toShort()
             _sectionId.value = section.id
 
@@ -106,7 +106,7 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
     }
 
     fun setPosition(pos: Vector3) {
-        change {
+        mutate {
             entity.setPosition(pos.x.toFloat(), pos.y.toFloat(), pos.z.toFloat())
 
             _position.value = pos
@@ -120,7 +120,7 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
     }
 
     fun setWorldPosition(pos: Vector3) {
-        change {
+        mutate {
             val section = section.value
 
             val relPos =
@@ -135,7 +135,7 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
     }
 
     fun setRotation(rot: Euler) {
-        change {
+        mutate {
             floorModEuler(rot)
 
             entity.setRotation(rot.x.toFloat(), rot.y.toFloat(), rot.z.toFloat())
@@ -155,7 +155,7 @@ abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
     }
 
     fun setWorldRotation(rot: Euler) {
-        change {
+        mutate {
             floorModEuler(rot)
 
             val section = section.value
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AsmStore.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AsmStore.kt
index 31be66c6..2b902018 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AsmStore.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/AsmStore.kt
@@ -9,6 +9,7 @@ import world.phantasmal.observable.Observable
 import world.phantasmal.observable.cell.Cell
 import world.phantasmal.observable.cell.list.ListCell
 import world.phantasmal.observable.cell.mutableCell
+import world.phantasmal.observable.mutateDeferred
 import world.phantasmal.psolib.asm.assemble
 import world.phantasmal.psolib.asm.disassemble
 import world.phantasmal.web.core.undo.UndoManager
@@ -111,8 +112,7 @@ class AsmStore(
     }
 
     private fun setTextModel(quest: QuestModel?, inlineStackArgs: Boolean) {
-        // TODO: Remove this hack.
-        window.setTimeout({
+        mutateDeferred {
             setBytecodeIrTimeout?.let { it ->
                 window.clearTimeout(it)
                 setBytecodeIrTimeout = null
@@ -120,7 +120,7 @@ class AsmStore(
 
             modelDisposer.disposeAll()
 
-            quest ?: return@setTimeout
+            quest ?: return@mutateDeferred
 
             val asm = disassemble(quest.bytecodeIr, inlineStackArgs)
             asmAnalyser.setAsm(asm, inlineStackArgs)
@@ -147,7 +147,7 @@ class AsmStore(
                     // TODO: Update breakpoints.
                 }
             }
-        }, 0)
+        }
     }
 
     private fun setBytecodeIr() {
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt
index 4057ded9..4daa07c4 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/stores/QuestEditorStore.kt
@@ -7,7 +7,7 @@ import world.phantasmal.observable.cell.list.ListCell
 import world.phantasmal.observable.cell.list.emptyListCell
 import world.phantasmal.observable.cell.list.filtered
 import world.phantasmal.observable.cell.list.flatMapToList
-import world.phantasmal.observable.change
+import world.phantasmal.observable.mutate
 import world.phantasmal.psolib.Episode
 import world.phantasmal.web.core.PwToolType
 import world.phantasmal.web.core.commands.Command
@@ -174,14 +174,14 @@ class QuestEditorStore(
     }
 
     fun addEvent(quest: QuestModel, index: Int, event: QuestEventModel) {
-        change {
+        mutate {
             quest.addEvent(index, event)
             setSelectedEvent(event)
         }
     }
 
     fun removeEvent(quest: QuestModel, event: QuestEventModel) {
-        change {
+        mutate {
             setSelectedEvent(null)
             quest.removeEvent(event)
         }
@@ -218,28 +218,28 @@ class QuestEditorStore(
         setter: (QuestEventModel, T) -> Unit,
         value: T,
     ) {
-        change {
+        mutate {
             setSelectedEvent(event)
             setter(event, value)
         }
     }
 
     fun addEventAction(event: QuestEventModel, action: QuestEventActionModel) {
-        change {
+        mutate {
             setSelectedEvent(event)
             event.addAction(action)
         }
     }
 
     fun addEventAction(event: QuestEventModel, index: Int, action: QuestEventActionModel) {
-        change {
+        mutate {
             setSelectedEvent(event)
             event.addAction(index, action)
         }
     }
 
     fun removeEventAction(event: QuestEventModel, action: QuestEventActionModel) {
-        change {
+        mutate {
             setSelectedEvent(event)
             event.removeAction(action)
         }
@@ -251,7 +251,7 @@ class QuestEditorStore(
         setter: (Action, T) -> Unit,
         value: T,
     ) {
-        change {
+        mutate {
             setSelectedEvent(event)
             setter(action, value)
         }
@@ -272,14 +272,14 @@ class QuestEditorStore(
     }
 
     fun addEntity(quest: QuestModel, entity: QuestEntityModel<*, *>) {
-        change {
+        mutate {
             quest.addEntity(entity)
             setSelectedEntity(entity)
         }
     }
 
     fun removeEntity(quest: QuestModel, entity: QuestEntityModel<*, *>) {
-        change {
+        mutate {
             if (entity == _selectedEntity.value) {
                 _selectedEntity.value = null
             }
@@ -289,7 +289,7 @@ class QuestEditorStore(
     }
 
     fun setEntityPosition(entity: QuestEntityModel<*, *>, sectionId: Int?, position: Vector3) {
-        change {
+        mutate {
             setSelectedEntity(entity)
             sectionId?.let { setEntitySection(entity, it) }
             entity.setPosition(position)
@@ -297,14 +297,14 @@ class QuestEditorStore(
     }
 
     fun setEntityRotation(entity: QuestEntityModel<*, *>, rotation: Euler) {
-        change {
+        mutate {
             setSelectedEntity(entity)
             entity.setRotation(rotation)
         }
     }
 
     fun setEntityWorldRotation(entity: QuestEntityModel<*, *>, rotation: Euler) {
-        change {
+        mutate {
             setSelectedEntity(entity)
             entity.setWorldRotation(rotation)
         }
@@ -315,14 +315,14 @@ class QuestEditorStore(
         setter: (Entity, T) -> Unit,
         value: T,
     ) {
-        change {
+        mutate {
             setSelectedEntity(entity)
             setter(entity, value)
         }
     }
 
     fun setEntityProp(entity: QuestEntityModel<*, *>, prop: QuestEntityPropModel, value: Any) {
-        change {
+        mutate {
             setSelectedEntity(entity)
             prop.setValue(value)
         }
@@ -336,14 +336,14 @@ class QuestEditorStore(
     }
 
     fun setEntitySectionId(entity: QuestEntityModel<*, *>, sectionId: Int) {
-        change {
+        mutate {
             setSelectedEntity(entity)
             entity.setSectionId(sectionId)
         }
     }
 
     fun setEntitySection(entity: QuestEntityModel<*, *>, section: SectionModel) {
-        change {
+        mutate {
             setSelectedEntity(entity)
             entity.setSection(section)
         }
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/undo/TextModelUndo.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/undo/TextModelUndo.kt
index e20b3d11..8ae07cc9 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/undo/TextModelUndo.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/undo/TextModelUndo.kt
@@ -1,12 +1,12 @@
 package world.phantasmal.web.questEditor.undo
 
-import kotlinx.browser.window
 import world.phantasmal.core.disposable.Disposable
 import world.phantasmal.core.disposable.TrackedDisposable
 import world.phantasmal.observable.ChangeEvent
 import world.phantasmal.observable.Observable
 import world.phantasmal.observable.cell.*
 import world.phantasmal.observable.emitter
+import world.phantasmal.observable.mutateDeferred
 import world.phantasmal.web.core.commands.Command
 import world.phantasmal.web.core.undo.Undo
 import world.phantasmal.web.core.undo.UndoManager
@@ -64,15 +64,14 @@ class TextModelUndo(
     }
 
     private fun onModelChange(model: ITextModel?) {
-        // TODO: Remove this hack.
-        window.setTimeout({
-            if (disposed) return@setTimeout
+        mutateDeferred {
+            if (disposed) return@mutateDeferred
 
             modelChangeObserver?.dispose()
 
             if (model == null) {
                 reset()
-                return@setTimeout
+                return@mutateDeferred
             }
 
             _canUndo.value = false
@@ -113,7 +112,7 @@ class TextModelUndo(
 
                 currentVersionId.value = versionId
             }
-        }, 0)
+        }
     }
 
     override fun undo(): Boolean =
diff --git a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AsmEditorWidget.kt b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AsmEditorWidget.kt
index 3876b511..4d2ed1eb 100644
--- a/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AsmEditorWidget.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/questEditor/widgets/AsmEditorWidget.kt
@@ -1,8 +1,8 @@
 package world.phantasmal.web.questEditor.widgets
 
-import kotlinx.browser.window
 import org.w3c.dom.Node
 import world.phantasmal.core.disposable.disposable
+import world.phantasmal.observable.mutateDeferred
 import world.phantasmal.web.externals.monacoEditor.*
 import world.phantasmal.web.questEditor.asm.monaco.EditorHistory
 import world.phantasmal.web.questEditor.controllers.AsmEditorController
@@ -64,31 +64,29 @@ class AsmEditorWidget(private val ctrl: AsmEditorController) : Widget() {
             observe(ctrl.didUndo) {
                 editor.focus()
 
-                // TODO: Remove this hack.
-                window.setTimeout({
-                    if (disposed) return@setTimeout
-
-                    editor.trigger(
-                        source = AsmEditorWidget::class.simpleName,
-                        handlerId = "undo",
-                        payload = undefined,
-                    )
-                }, 0)
+                mutateDeferred {
+                    if (!disposed) {
+                        editor.trigger(
+                            source = AsmEditorWidget::class.simpleName,
+                            handlerId = "undo",
+                            payload = undefined,
+                        )
+                    }
+                }
             }
 
             observe(ctrl.didRedo) {
                 editor.focus()
 
-                // TODO: Remove this hack.
-                window.setTimeout({
-                    if (disposed) return@setTimeout
-
-                    editor.trigger(
-                        source = AsmEditorWidget::class.simpleName,
-                        handlerId = "redo",
-                        payload = undefined,
-                    )
-                }, 0)
+                mutateDeferred {
+                    if (!disposed) {
+                        editor.trigger(
+                            source = AsmEditorWidget::class.simpleName,
+                            handlerId = "redo",
+                            payload = undefined,
+                        )
+                    }
+                }
             }
 
             editor.onDidFocusEditorWidget(ctrl::makeUndoCurrent)
diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt
index c3e862f8..31dc923f 100644
--- a/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/viewer/controllers/ViewerToolbarController.kt
@@ -17,7 +17,7 @@ import world.phantasmal.observable.cell.Cell
 import world.phantasmal.observable.cell.isNotNull
 import world.phantasmal.observable.cell.map
 import world.phantasmal.observable.cell.mutableCell
-import world.phantasmal.observable.change
+import world.phantasmal.observable.mutate
 import world.phantasmal.web.core.files.cursor
 import world.phantasmal.web.viewer.stores.NinjaGeometry
 import world.phantasmal.web.viewer.stores.ViewerStore
@@ -166,7 +166,7 @@ class ViewerToolbarController(private val store: ViewerStore) : Controller() {
             result.addProblem(Severity.Error, "Couldn't parse files.", cause = e)
         }
 
-        change {
+        mutate {
             ninjaGeometry?.let(store::setCurrentNinjaGeometry)
             textures?.let(store::setCurrentTextures)
             ninjaMotion?.let(store::setCurrentNinjaMotion)
diff --git a/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt b/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt
index 6ea633ad..9fc10d05 100644
--- a/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt
+++ b/web/src/main/kotlin/world/phantasmal/web/viewer/stores/ViewerStore.kt
@@ -10,7 +10,7 @@ import world.phantasmal.observable.cell.list.ListCell
 import world.phantasmal.observable.cell.list.mutableListCell
 import world.phantasmal.observable.cell.map
 import world.phantasmal.observable.cell.mutableCell
-import world.phantasmal.observable.change
+import world.phantasmal.observable.mutate
 import world.phantasmal.psolib.fileFormats.AreaGeometry
 import world.phantasmal.psolib.fileFormats.CollisionGeometry
 import world.phantasmal.psolib.fileFormats.ninja.NinjaObject
@@ -176,7 +176,7 @@ class ViewerStore(
     }
 
     fun setCurrentNinjaGeometry(geometry: NinjaGeometry?) {
-        change {
+        mutate {
             if (_currentCharacterClass.value != null) {
                 setCurrentCharacterClassValue(null)
                 _currentTextures.clear()
@@ -215,7 +215,7 @@ class ViewerStore(
     }
 
     fun setCurrentNinjaMotion(njm: NjMotion) {
-        change {
+        mutate {
             _currentNinjaMotion.value = njm
             _animationPlaying.value = true
         }
@@ -267,7 +267,7 @@ class ViewerStore(
             val ninjaObject = characterClassAssetLoader.loadNinjaObject(char)
             val textures = characterClassAssetLoader.loadXvrTextures(char, sectionId, body)
 
-            change {
+            mutate {
                 if (clearAnimation) {
                     _currentAnimation.value = null
                     _currentNinjaMotion.value = null
@@ -279,7 +279,7 @@ class ViewerStore(
         } catch (e: Exception) {
             logger.error(e) { "Couldn't load Ninja model for $char." }
 
-            change {
+            mutate {
                 _currentAnimation.value = null
                 _currentNinjaMotion.value = null
                 _currentNinjaGeometry.value = null
@@ -292,7 +292,7 @@ class ViewerStore(
         try {
             val ninjaMotion = animationAssetLoader.loadAnimation(animation.filePath)
 
-            change {
+            mutate {
                 _currentNinjaMotion.value = ninjaMotion
                 _animationPlaying.value = true
             }
diff --git a/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt b/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt
index 4ef78e28..5c14a5a0 100644
--- a/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt
+++ b/web/src/test/kotlin/world/phantasmal/web/test/TestComponents.kt
@@ -23,6 +23,7 @@ import world.phantasmal.web.questEditor.loading.AreaAssetLoader
 import world.phantasmal.web.questEditor.loading.QuestLoader
 import world.phantasmal.web.questEditor.stores.AreaStore
 import world.phantasmal.web.questEditor.stores.QuestEditorStore
+import world.phantasmal.web.shared.JSON_FORMAT
 import world.phantasmal.web.viewer.loading.AnimationAssetLoader
 import world.phantasmal.web.viewer.loading.CharacterClassAssetLoader
 import world.phantasmal.web.viewer.stores.ViewerStore
@@ -37,9 +38,7 @@ class TestComponents(private val ctx: TestContext) {
     var httpClient: HttpClient by default {
         HttpClient {
             install(JsonFeature) {
-                serializer = KotlinxSerializer(kotlinx.serialization.json.Json {
-                    ignoreUnknownKeys = true
-                })
+                serializer = KotlinxSerializer(JSON_FORMAT)
             }
         }.also {
             ctx.disposer.add(disposable { it.cancel() })
diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt
index 09c0034f..b2e47ebe 100644
--- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt
+++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/LazyLoader.kt
@@ -1,9 +1,9 @@
 package world.phantasmal.webui.widgets
 
-import kotlinx.browser.window
 import org.w3c.dom.Node
 import world.phantasmal.observable.cell.Cell
 import world.phantasmal.observable.cell.trueCell
+import world.phantasmal.observable.mutateDeferred
 import world.phantasmal.webui.dom.div
 
 class LazyLoader(
@@ -21,11 +21,11 @@ class LazyLoader(
                 if (v && !initialized) {
                     initialized = true
 
-                    // TODO: Remove this hack.
-                    window.setTimeout({
-                        if (disposed) return@setTimeout
-                        addChild(createWidget())
-                    }, 0)
+                    mutateDeferred {
+                        if (!disposed) {
+                            addChild(createWidget())
+                        }
+                    }
                 }
             }
         }
diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt
index 5b6c5fbd..ab1aacfb 100644
--- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt
+++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/TabContainer.kt
@@ -54,8 +54,8 @@ class TabContainer<T : Tab>(
             }
         }
 
-    init {
-        observeNow(selfOrAncestorVisible, ctrl::visibleChanged)
+    override fun selfAndAncestorsVisibleChanged(visible: Boolean) {
+        ctrl.visibleChanged(visible)
     }
 
     companion object {
diff --git a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt
index 70e947d9..4212f5fd 100644
--- a/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt
+++ b/webui/src/main/kotlin/world/phantasmal/webui/widgets/Widget.kt
@@ -1,7 +1,6 @@
 package world.phantasmal.webui.widgets
 
 import kotlinx.browser.document
-import kotlinx.browser.window
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
@@ -13,7 +12,9 @@ import org.w3c.dom.pointerevents.PointerEvent
 import world.phantasmal.core.disposable.Disposable
 import world.phantasmal.core.disposable.DisposableSupervisedScope
 import world.phantasmal.core.disposable.disposable
-import world.phantasmal.observable.cell.*
+import world.phantasmal.observable.cell.Cell
+import world.phantasmal.observable.cell.nullCell
+import world.phantasmal.observable.cell.trueCell
 import world.phantasmal.webui.DisposableContainer
 import world.phantasmal.webui.dom.*
 
@@ -29,7 +30,9 @@ abstract class Widget(
     val enabled: Cell<Boolean> = trueCell(),
     val tooltip: Cell<String?> = nullCell(),
 ) : DisposableContainer() {
-    private val _ancestorVisible = mutableCell(true)
+    protected var ancestorsVisible = true
+        private set
+
     private val _children = mutableListOf<Widget>()
     private val _size = HTMLElementSizeCell()
 
@@ -39,14 +42,13 @@ abstract class Widget(
         observeNow(visible) { visible ->
             el.hidden = !visible
 
-            // TODO: Remove this hack.
-            window.setTimeout({
-                if (disposed) return@setTimeout
+            val selfAndAncestorsVisible = visible && ancestorsVisible
 
-                for (child in children) {
-                    setAncestorVisible(child, visible && ancestorVisible.value)
-                }
-            }, 0)
+            selfAndAncestorsVisibleChanged(selfAndAncestorsVisible)
+
+            for (child in children) {
+                setAncestorsVisible(child, selfAndAncestorsVisible)
+            }
         }
 
         observeNow(enabled) { enabled ->
@@ -82,16 +84,6 @@ abstract class Widget(
      */
     val element: HTMLElement by elementDelegate
 
-    /**
-     * True if this widget's ancestors are [visible], false otherwise.
-     */
-    val ancestorVisible: Cell<Boolean> get() = _ancestorVisible
-
-    /**
-     * True if this widget and all of its ancestors are [visible], false otherwise.
-     */
-    val selfOrAncestorVisible: Cell<Boolean> = visible and ancestorVisible
-
     val size: Cell<Size> get() = _size
 
     val children: List<Widget> get() = _children
@@ -155,7 +147,7 @@ abstract class Widget(
         }
 
         _children.add(child)
-        setAncestorVisible(child, selfOrAncestorVisible.value)
+        setAncestorsVisible(child, visible.value && ancestorsVisible)
         appendChild(child.element)
         return child
     }
@@ -205,7 +197,11 @@ abstract class Widget(
         addDisposable(bindDisposableChildrenTo(this, list, create))
     }
 
-    fun Element.onDrag(
+    protected open fun selfAndAncestorsVisibleChanged(visible: Boolean) {
+        // Do nothing.
+    }
+
+    protected fun Element.onDrag(
         onPointerDown: (e: PointerEvent) -> Boolean,
         onPointerMove: (movedX: Int, movedY: Int, e: PointerEvent) -> Boolean,
         onPointerUp: (e: PointerEvent) -> Unit = {},
@@ -229,13 +225,15 @@ abstract class Widget(
             STYLE_EL.append(style)
         }
 
-        protected fun setAncestorVisible(widget: Widget, visible: Boolean) {
-            widget._ancestorVisible.value = visible
+        protected fun setAncestorsVisible(widget: Widget, ancestorsVisible: Boolean) {
+            widget.ancestorsVisible = ancestorsVisible
 
             if (!widget.visible.value) return
 
-            widget.children.forEach {
-                setAncestorVisible(it, widget.selfOrAncestorVisible.value)
+            widget.selfAndAncestorsVisibleChanged(ancestorsVisible)
+
+            for (child in widget.children) {
+                setAncestorsVisible(child, ancestorsVisible)
             }
         }
     }
diff --git a/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt b/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt
index 5a0e3227..421462f0 100644
--- a/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt
+++ b/webui/src/test/kotlin/world/phantasmal/webui/widgets/WidgetTests.kt
@@ -1,7 +1,5 @@
 package world.phantasmal.webui.widgets
 
-import kotlinx.browser.window
-import kotlinx.coroutines.await
 import org.w3c.dom.Node
 import world.phantasmal.observable.cell.Cell
 import world.phantasmal.observable.cell.falseCell
@@ -10,7 +8,6 @@ import world.phantasmal.observable.cell.mutableCell
 import world.phantasmal.observable.cell.trueCell
 import world.phantasmal.webui.dom.div
 import world.phantasmal.webui.test.WebuiTestSuite
-import kotlin.js.Promise
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFalse
@@ -45,48 +42,42 @@ class WidgetTests : WebuiTestSuite {
     }
 
     @Test
-    fun ancestorVisible_and_selfOrAncestorVisible_update_when_visible_changes() = testAsync {
-        val parentVisible = mutableCell(true)
-        val childVisible = mutableCell(true)
-        val grandChild = DummyWidget()
-        val child = DummyWidget(childVisible, grandChild)
-        val parent = disposer.add(DummyWidget(parentVisible, child))
+    fun ancestorVisible_updates_and_selfAndAncestorsVisibleChanged_is_called_when_visible_changes() =
+        testAsync {
+            val parentVisible = mutableCell(true)
+            val childVisible = mutableCell(true)
+            val grandChild = DummyWidget()
+            val child = DummyWidget(childVisible, grandChild)
+            val parent = disposer.add(DummyWidget(parentVisible, child))
 
-        parent.element // Ensure widgets are fully initialized.
+            parent.element // Ensure widgets are fully initialized.
 
-        assertTrue(parent.ancestorVisible.value)
-        assertTrue(parent.selfOrAncestorVisible.value)
-        assertTrue(child.ancestorVisible.value)
-        assertTrue(child.selfOrAncestorVisible.value)
-        assertTrue(grandChild.ancestorVisible.value)
-        assertTrue(grandChild.selfOrAncestorVisible.value)
+            assertTrue(parent.publicAncestorsVisible)
+            assertEquals(true, parent.selfAndAncestorsVisible)
+            assertTrue(child.publicAncestorsVisible)
+            assertEquals(true, child.selfAndAncestorsVisible)
+            assertTrue(grandChild.publicAncestorsVisible)
+            assertEquals(true, grandChild.selfAndAncestorsVisible)
 
-        parentVisible.value = false
-        setTimeoutHack()
+            parentVisible.value = false
 
-        assertTrue(parent.ancestorVisible.value)
-        assertFalse(parent.selfOrAncestorVisible.value)
-        assertFalse(child.ancestorVisible.value)
-        assertFalse(child.selfOrAncestorVisible.value)
-        assertFalse(grandChild.ancestorVisible.value)
-        assertFalse(grandChild.selfOrAncestorVisible.value)
+            assertTrue(parent.publicAncestorsVisible)
+            assertEquals(false, parent.selfAndAncestorsVisible)
+            assertFalse(child.publicAncestorsVisible)
+            assertEquals(false, child.selfAndAncestorsVisible)
+            assertFalse(grandChild.publicAncestorsVisible)
+            assertEquals(false, grandChild.selfAndAncestorsVisible)
 
-        childVisible.value = false
-        parentVisible.value = true
-        setTimeoutHack()
+            childVisible.value = false
+            parentVisible.value = true
 
-        assertTrue(parent.ancestorVisible.value)
-        assertTrue(parent.selfOrAncestorVisible.value)
-        assertTrue(child.ancestorVisible.value)
-        assertFalse(child.selfOrAncestorVisible.value)
-        assertFalse(grandChild.ancestorVisible.value)
-        assertFalse(grandChild.selfOrAncestorVisible.value)
-    }
-
-    // TODO: Remove test setTimeout hack when setTimeout hack in Widget visible observer is removed.
-    private suspend fun setTimeoutHack() {
-        Promise<Unit> { resolve, _ -> window.setTimeout(resolve, 10) }.await()
-    }
+            assertTrue(parent.publicAncestorsVisible)
+            assertEquals(true, parent.selfAndAncestorsVisible)
+            assertTrue(child.publicAncestorsVisible)
+            assertEquals(false, child.selfAndAncestorsVisible)
+            assertFalse(grandChild.publicAncestorsVisible)
+            assertEquals(false, grandChild.selfAndAncestorsVisible)
+        }
 
     @Test
     fun added_child_widgets_have_ancestorVisible_and_selfOrAncestorVisible_set_correctly() =
@@ -94,10 +85,10 @@ class WidgetTests : WebuiTestSuite {
             val parent = disposer.add(DummyWidget(visible = falseCell()))
             val child = parent.addChild(DummyWidget())
 
-            assertTrue(parent.ancestorVisible.value)
-            assertFalse(parent.selfOrAncestorVisible.value)
-            assertFalse(child.ancestorVisible.value)
-            assertFalse(child.selfOrAncestorVisible.value)
+            assertTrue(parent.publicAncestorsVisible)
+            assertEquals(false, parent.selfAndAncestorsVisible)
+            assertFalse(child.publicAncestorsVisible)
+            assertEquals(false, child.selfAndAncestorsVisible)
         }
 
     @Test
@@ -149,6 +140,10 @@ class WidgetTests : WebuiTestSuite {
         visible: Cell<Boolean> = trueCell(),
         private val child: Widget? = null,
     ) : Widget(visible = visible) {
+        val publicAncestorsVisible: Boolean get() = ancestorsVisible
+        var selfAndAncestorsVisible: Boolean? = null
+            private set
+
         override fun Node.createElement() =
             div {
                 child?.let { addChild(it) }
@@ -156,5 +151,9 @@ class WidgetTests : WebuiTestSuite {
 
         fun <T : Widget> addChild(child: T): T =
             element.addChild(child)
+
+        override fun selfAndAncestorsVisibleChanged(visible: Boolean) {
+            selfAndAncestorsVisible = visible
+        }
     }
 }