diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3bad4281f..3676579410d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.33.0 + +* `Versions`: + * `Exposed`: `1.5.1` -> `1.6.0` + * `Serialization`: `1.11.0` -> `1.12.0` + * `Compose`: `1.12.1` -> `1.12.2` + * `SQLite`: `3.53.4.0` -> `3.54.1.0` + * `Ktor`: `3.6.0` -> `3.6.1` +* `Common`: + * Add JS `MutationObserverOptions` 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..00e68211c70 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 @@ -17,7 +19,7 @@ fun Node.onRemoved(block: () -> Unit): MutationObserver { } } - observer.observe(document, MutationObserverInit(childList = true, subtree = true)) + observer.observe(document, MutationObserverOptions(childList = true, subtree = true)) return observer } 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..77f6be2d775 --- /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 + +/** + * 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 MutationObserverOptions( + childList: Boolean? = null, + attributes: Boolean? = null, + characterData: Boolean? = null, + subtree: Boolean? = null, + attributeOldValue: Boolean? = null, + characterDataOldValue: Boolean? = null, + attributeFilter: Array? = null +): MutationObserverInit = 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..0e111be8a58 --- /dev/null +++ b/common/src/jsTest/kotlin/dev/inmo/micro_utils/common/MutationObserverBrowserTests.kt @@ -0,0 +1,163 @@ +package dev.inmo.micro_utils.common + +import kotlinx.browser.document +import org.w3c.dom.MutationObserver +import org.w3c.dom.events.Event +import org.w3c.dom.events.EventListener +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, MutationObserverOptions(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, MutationObserverOptions(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, MutationObserverOptions(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, MutationObserverOptions(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, MutationObserverOptions(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, MutationObserverOptions()) } + assertFails { + observer.observe(target, MutationObserverOptions(attributes = false, attributeFilter = arrayOf("class"))) + } + assertFails { + observer.observe(target, MutationObserverOptions(characterData = false, characterDataOldValue = true)) + } + } finally { + observer.disconnect() + } + } + + @Test + fun onRemovedCallsBackOnceAndDisconnects(): Promise { + val parent = document.createElement("div") + val child = document.createElement("span") + var calls = 0 + var observer: MutationObserver? = null + return Promise.resolve(Unit).then { + parent.appendChild(child) + document.body!!.appendChild(parent) + observer = child.onRemoved { calls++ } + parent.removeChild(child) + }.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") + var calls = 0 + var listener: EventListener? = null + return Promise.resolve(Unit).then { + document.body!!.appendChild(target) + listener = target.onClickOutside { calls++ } + document.dispatchEvent(Event("click")) + assertEquals(1, calls) + target.parentNode!!.removeChild(target) + }.then { + document.dispatchEvent(Event("click")) + assertEquals(1, calls) + }.finally { + listener?.let { document.removeEventListener("click", it) } + 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..783ea1f75f0 --- /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 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class MutationObserverInitTests { + private fun keys(@Suppress("unused") options: MutationObserverInit): List = + js("Object.keys(options)").unsafeCast>().sorted() + + @Test + fun emptyOptionsHaveNoProperties() { + assertEquals(emptyList(), keys(MutationObserverOptions())) + } + + @Test + fun childListOptionsOmitUnspecifiedProperties() { + val options = MutationObserverOptions(childList = true, subtree = true) + assertEquals(listOf("childList", "subtree"), keys(options)) + assertEquals(true, options.childList) + assertEquals(true, options.subtree) + } + + @Test + fun explicitNullOptionsAreOmitted() { + val options = MutationObserverOptions( + 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 = MutationObserverOptions(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 = MutationObserverOptions( + 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 = MutationObserverOptions(childList = true) + val second = MutationObserverOptions(attributes = true) + first.subtree = true + assertEquals(listOf("childList", "subtree"), keys(first)) + assertEquals(listOf("attributes"), keys(second)) + } +} diff --git a/gradle.properties b/gradle.properties index 88c615f5985..1162f4dbb80 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,5 +16,5 @@ crypto_js_version=4.2.0 # Project data group=dev.inmo -version=0.32.1 -android_code_version=320 +version=0.33.0 +android_code_version=321 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e06b3767319..d43b635a3ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,25 +1,25 @@ [versions] kt = "2.4.21" -kt-serialization = "1.11.0" +kt-serialization = "1.12.0" kt-coroutines = "1.11.0" kotlinx-browser = "0.5.0" kslog = "2.1.0" -jb-compose = "1.12.1" +jb-compose = "1.12.2" jb-compose-material3 = "1.9.0" jb-compose-icons = "1.7.8" -jb-exposed = "1.5.1" +jb-exposed = "1.6.0" jb-dokka = "2.2.0" -sqlite = "3.53.4.0" +sqlite = "3.54.1.0" korlibs = "5.4.0" uuid = "0.8.4" -ktor = "3.6.0" +ktor = "3.6.1" gh-release = "2.5.2"