From 19ca5e14876cad8d87246b9a6ebe36c6ea7fa9ee Mon Sep 17 00:00:00 2001 From: InsanusMokrassar Date: Sun, 1 Nov 2026 13:54:10 +0600 Subject: [PATCH] fix MutationObserver options in JS --- CHANGELOG.md | 4 + common/build.gradle | 16 ++ .../common/HTMLElementDomChanged.kt | 4 +- .../micro_utils/common/MutationObserver.kt | 31 ++++ .../common/MutationObserverBrowserTests.kt | 158 ++++++++++++++++++ .../common/MutationObserverInitTests.kt | 80 +++++++++ 6 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 common/src/jsMain/kotlin/dev/inmo/micro_utils/common/MutationObserver.kt create mode 100644 common/src/jsTest/kotlin/dev/inmo/micro_utils/common/MutationObserverBrowserTests.kt create mode 100644 common/src/jsTest/kotlin/dev/inmo/micro_utils/common/MutationObserverInitTests.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 454d6a3a8af..0a51631e9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.33.0 +* `Common`: + * Add JS `MutationObserverInit` factory that omits unspecified options and preserves browser defaults. + * Use safe `MutationObserver` options in `onRemoved` and cover DOM observation and outside-click cleanup with browser tests. + ## 0.32.1 * `Coroutines`: diff --git a/common/build.gradle b/common/build.gradle index 058da15463d..c8b2193b674 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -7,6 +7,22 @@ plugins { apply from: "$mppJvmJsWasmJsAndroidLinuxMingwLinuxArm64Project" kotlin { + js { + browser { + testTask { + useKarma { + useChromeHeadlessNoSandbox() + useConfigDirectory(rootProject.relativeProjectPath("gradle/karma.config.d")) + } + } + } + nodejs { + testTask { + filter.excludeTestsMatching("dev.inmo.micro_utils.common.MutationObserverBrowserTests*") + } + } + } + sourceSets { commonMain { dependencies { diff --git a/common/src/jsMain/kotlin/dev/inmo/micro_utils/common/HTMLElementDomChanged.kt b/common/src/jsMain/kotlin/dev/inmo/micro_utils/common/HTMLElementDomChanged.kt index 825414a3587..eb33eb79b21 100644 --- a/common/src/jsMain/kotlin/dev/inmo/micro_utils/common/HTMLElementDomChanged.kt +++ b/common/src/jsMain/kotlin/dev/inmo/micro_utils/common/HTMLElementDomChanged.kt @@ -1,7 +1,9 @@ package dev.inmo.micro_utils.common import kotlinx.browser.document -import org.w3c.dom.* +import org.w3c.dom.Element +import org.w3c.dom.MutationObserver +import org.w3c.dom.Node fun Node.onRemoved(block: () -> Unit): MutationObserver { lateinit var observer: MutationObserver diff --git a/common/src/jsMain/kotlin/dev/inmo/micro_utils/common/MutationObserver.kt b/common/src/jsMain/kotlin/dev/inmo/micro_utils/common/MutationObserver.kt new file mode 100644 index 00000000000..f2493a9217a --- /dev/null +++ b/common/src/jsMain/kotlin/dev/inmo/micro_utils/common/MutationObserver.kt @@ -0,0 +1,31 @@ +package dev.inmo.micro_utils.common + +import org.w3c.dom.MutationObserverInit as NativeMutationObserverInit + +/** + * Creates options for [org.w3c.dom.MutationObserver] without adding unspecified properties. + * + * Unlike the Kotlin DOM factory, this factory leaves absent options out of the JavaScript object. In particular, + * an absent [attributeFilter] must not become a null sequence, and absent [attributes] and [characterData] must not + * override the browser defaults implied by their filters or old-value options. + * + * Null arguments are omitted. Explicit false values and empty arrays are preserved. The browser remains responsible + * for validating the options passed to [org.w3c.dom.MutationObserver.observe]. + */ +fun MutationObserverInit( + childList: Boolean? = null, + attributes: Boolean? = null, + characterData: Boolean? = null, + subtree: Boolean? = null, + attributeOldValue: Boolean? = null, + characterDataOldValue: Boolean? = null, + attributeFilter: Array? = null +): NativeMutationObserverInit = js("{}").unsafeCast().apply { + if (childList != null) this.childList = childList + if (attributes != null) this.attributes = attributes + if (characterData != null) this.characterData = characterData + if (subtree != null) this.subtree = subtree + if (attributeOldValue != null) this.attributeOldValue = attributeOldValue + if (characterDataOldValue != null) this.characterDataOldValue = characterDataOldValue + if (attributeFilter != null) this.attributeFilter = attributeFilter +} diff --git a/common/src/jsTest/kotlin/dev/inmo/micro_utils/common/MutationObserverBrowserTests.kt b/common/src/jsTest/kotlin/dev/inmo/micro_utils/common/MutationObserverBrowserTests.kt new file mode 100644 index 00000000000..a29c8f17e4b --- /dev/null +++ b/common/src/jsTest/kotlin/dev/inmo/micro_utils/common/MutationObserverBrowserTests.kt @@ -0,0 +1,158 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.document +import org.w3c.dom.MutationObserver +import org.w3c.dom.events.Event +import kotlin.js.Promise +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Runs only in the browser: jsNodeTest excludes this class rather than reporting DOM checks as passing. */ +class MutationObserverBrowserTests { + @Test + fun childListSubtreeRecordsCanBeDrainedAndDisconnected() { + val root = document.createElement("div") + val nested = document.createElement("div") + val child = document.createElement("span") + root.appendChild(nested) + val observer = MutationObserver { _, _ -> } + try { + observer.observe(root, MutationObserverInit(childList = true, subtree = true)) + nested.appendChild(child) + val record = observer.takeRecords().single() + assertEquals("childList", record.type) + assertSame(nested, record.target) + assertSame(child, record.addedNodes.item(0)) + assertEquals(0, record.removedNodes.length) + assertNull(record.attributeName) + assertTrue(observer.takeRecords().isEmpty()) + observer.disconnect() + nested.removeChild(child) + assertTrue(observer.takeRecords().isEmpty()) + } finally { + observer.disconnect() + } + } + + @Test + fun attributeFilterEnablesObservationAndPreservesOldValues() { + val target = document.createElement("div") + target.setAttribute("data-state", "old") + val observer = MutationObserver { _, _ -> } + try { + observer.observe(target, MutationObserverInit(attributeFilter = arrayOf("data-state"), attributeOldValue = true)) + target.setAttribute("ignored", "value") + target.setAttribute("data-state", "new") + val record = observer.takeRecords().single() + assertEquals("attributes", record.type) + assertSame(target, record.target) + assertEquals("data-state", record.attributeName) + assertEquals("old", record.oldValue) + assertNull(record.attributeNamespace) + } finally { + observer.disconnect() + } + } + + @Test + fun characterDataOldValueEnablesObservation() { + val target = document.createTextNode("old") + val observer = MutationObserver { _, _ -> } + try { + observer.observe(target, MutationObserverInit(characterDataOldValue = true)) + target.data = "new" + val record = observer.takeRecords().single() + assertEquals("characterData", record.type) + assertSame(target, record.target) + assertEquals("old", record.oldValue) + } finally { + observer.disconnect() + } + } + + @Test + fun nullAttributeFilterIsOmittedInsteadOfConvertedToASequence() { + val target = document.createElement("div") + val observer = MutationObserver { _, _ -> } + try { + observer.observe(target, MutationObserverInit(childList = true, attributeFilter = null)) + target.appendChild(document.createElement("span")) + assertEquals("childList", observer.takeRecords().single().type) + } finally { + observer.disconnect() + } + } + + @Test + fun emptyAttributeFilterDoesNotObserveAllAttributes() { + val target = document.createElement("div") + val observer = MutationObserver { _, _ -> } + try { + observer.observe(target, MutationObserverInit(attributeFilter = emptyArray())) + target.setAttribute("data-state", "new") + assertTrue(observer.takeRecords().isEmpty()) + } finally { + observer.disconnect() + } + } + + @Test + fun nativeValidationRejectsContradictoryOptions() { + val target = document.createElement("div") + val observer = MutationObserver { _, _ -> } + try { + assertFails { observer.observe(target, MutationObserverInit()) } + assertFails { + observer.observe(target, MutationObserverInit(attributes = false, attributeFilter = arrayOf("class"))) + } + assertFails { + observer.observe(target, MutationObserverInit(characterData = false, characterDataOldValue = true)) + } + } finally { + observer.disconnect() + } + } + + @Test + fun onRemovedCallsBackOnceAndDisconnects(): Promise { + val parent = document.createElement("div") + val child = document.createElement("span") + parent.appendChild(child) + document.body!!.appendChild(parent) + var calls = 0 + val observer = child.onRemoved { calls++ } + parent.removeChild(child) + return Promise.resolve(Unit).then { + assertEquals(1, calls) + parent.appendChild(child) + parent.removeChild(child) + }.then { + assertEquals(1, calls) + }.finally { + observer.disconnect() + parent.parentNode?.removeChild(parent) + } + } + + @Test + fun onClickOutsideStopsListeningAfterRemoval(): Promise { + val target = document.createElement("div") + document.body!!.appendChild(target) + var calls = 0 + val listener = target.onClickOutside { calls++ } + document.dispatchEvent(Event("click")) + assertEquals(1, calls) + target.parentNode!!.removeChild(target) + return Promise.resolve(Unit).then { + document.dispatchEvent(Event("click")) + assertEquals(1, calls) + }.finally { + document.removeEventListener("click", listener) + target.parentNode?.removeChild(target) + } + } +} diff --git a/common/src/jsTest/kotlin/dev/inmo/micro_utils/common/MutationObserverInitTests.kt b/common/src/jsTest/kotlin/dev/inmo/micro_utils/common/MutationObserverInitTests.kt new file mode 100644 index 00000000000..9f5fc20ed71 --- /dev/null +++ b/common/src/jsTest/kotlin/dev/inmo/micro_utils/common/MutationObserverInitTests.kt @@ -0,0 +1,80 @@ +package dev.inmo.micro_utils.common + +import org.w3c.dom.MutationObserverInit as NativeMutationObserverInit +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class MutationObserverInitTests { + private fun keys(options: NativeMutationObserverInit): List = + js("Object.keys(options)").unsafeCast>().sorted() + + @Test + fun emptyOptionsHaveNoProperties() { + assertEquals(emptyList(), keys(MutationObserverInit())) + } + + @Test + fun childListOptionsOmitUnspecifiedProperties() { + val options = MutationObserverInit(childList = true, subtree = true) + assertEquals(listOf("childList", "subtree"), keys(options)) + assertEquals(true, options.childList) + assertEquals(true, options.subtree) + } + + @Test + fun explicitNullOptionsAreOmitted() { + val options = MutationObserverInit( + childList = null, + attributes = null, + characterData = null, + subtree = null, + attributeOldValue = null, + characterDataOldValue = null, + attributeFilter = null + ) + assertEquals(emptyList(), keys(options)) + } + + @Test + fun explicitFalseAndEmptyFilterArePreserved() { + val filter = emptyArray() + val options = MutationObserverInit(false, false, false, false, false, false, filter) + assertEquals( + listOf("attributeFilter", "attributeOldValue", "attributes", "characterData", "characterDataOldValue", "childList", "subtree"), + keys(options) + ) + assertEquals(false, options.childList) + assertEquals(false, options.attributes) + assertEquals(false, options.characterData) + assertEquals(false, options.subtree) + assertEquals(false, options.attributeOldValue) + assertEquals(false, options.characterDataOldValue) + assertSame(filter, options.attributeFilter) + assertContentEquals(emptyArray(), options.attributeFilter) + } + + @Test + fun filtersAndOldValueOptionsDoNotForceObservationFlags() { + val filter = arrayOf("class", "data-state") + val options = MutationObserverInit( + attributeFilter = filter, + attributeOldValue = true, + characterDataOldValue = true + ) + assertEquals(listOf("attributeFilter", "attributeOldValue", "characterDataOldValue"), keys(options)) + assertSame(filter, options.attributeFilter) + assertEquals(true, options.attributeOldValue) + assertEquals(true, options.characterDataOldValue) + } + + @Test + fun eachCallCreatesIndependentOptions() { + val first = MutationObserverInit(childList = true) + val second = MutationObserverInit(attributes = true) + first.subtree = true + assertEquals(listOf("childList", "subtree"), keys(first)) + assertEquals(listOf("attributes"), keys(second)) + } +}