v.1.2.9 - Fix memory leak
Some checks failed
Gradle CI / build (push) Has been cancelled

This commit is contained in:
2025-09-19 19:19:07 +02:00
parent 191b23ed51
commit d3fef98203
9 changed files with 10 additions and 1027 deletions

View File

@@ -11,7 +11,7 @@ plugins {
}
group = "nl.astraeus"
version = "1.2.8"
version = "1.2.9"
repositories {
mavenCentral()

View File

@@ -1,9 +1,8 @@
package nl.astraeus.komp
import org.w3c.dom.events.Event
import org.w3c.dom.Element
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.events.EventListener
import org.w3c.dom.events.Event
import org.w3c.dom.get
private fun Int.asSpaces(): String {
@@ -129,10 +128,15 @@ internal fun Element.setKompEvent(name: String, event: (Event) -> Unit) {
this.addEventListener(eventName, event)
}
private val kompEvents = mutableMapOf<Element, MutableMap<String, (Event) -> Unit>>()
internal fun Element.getKompEvents(): MutableMap<String, (Event) -> Unit> {
return kompEvents.getOrPut(this) { mutableMapOf() }
var map = this.asDynamic()["komp-events"] as? MutableMap<String, (Event) -> Unit>
if (map == null) {
map = mutableMapOf()
this.asDynamic()["komp-events"] = map
}
return map
}
internal fun Element.findElementIndex(): Int {

View File

@@ -1,152 +0,0 @@
package nl.astraeus.komp
import org.w3c.dom.Element
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.events.Event
import org.w3c.dom.get
private fun Int.asSpaces(): String {
val result = StringBuilder()
repeat(this) {
result.append(" ")
}
return result.toString()
}
fun Element.printTree(indent: Int = 0): String {
val result = StringBuilder()
result.append(indent.asSpaces())
result.append(tagName)
if (this.namespaceURI != "http://www.w3.org/1999/xhtml") {
result.append(" [")
result.append(namespaceURI)
result.append("]")
}
result.append(" (")
var first = true
if (hasAttributes()) {
for (index in 0 until attributes.length) {
if (!first) {
result.append(", ")
} else {
first = false
}
result.append(attributes[index]?.localName)
result.append("=")
result.append(attributes[index]?.value)
}
}
result.append(") {")
result.append("\n")
for ((name, event) in getKompEvents()) {
result.append(indent.asSpaces())
result.append("on")
result.append(name)
result.append(" -> ")
result.append(event)
result.append("\n")
}
for (index in 0 until childNodes.length) {
childNodes[index]?.let {
if (it is Element) {
result.append(it.printTree(indent + 2))
} else {
result.append((indent + 2).asSpaces())
result.append(it.textContent)
result.append("\n")
}
}
}
result.append(indent.asSpaces())
result.append("}\n")
return result.toString()
}
internal fun Element.setKompAttribute(attributeName: String, value: String?) {
//val attributeName = name.lowercase()
if (value == null || value.isBlank()) {
if (this is HTMLInputElement) {
when (attributeName) {
"checked" -> {
checked = false
}
"class" -> {
className = ""
}
"value" -> {
this.value = ""
}
else -> {
removeAttribute(attributeName)
}
}
} else if (attributeName == "class") {
className = ""
} else {
removeAttribute(attributeName)
}
} else {
if (this is HTMLInputElement) {
when (attributeName) {
"checked" -> {
checked = "checked" == value
}
"class" -> {
className = value
}
"value" -> {
this.value = value
}
else -> {
setAttribute(attributeName, value)
}
}
} else if (attributeName == "class") {
className = value
} else if (this.getAttribute(attributeName) != value) {
setAttribute(attributeName, value)
}
}
}
internal fun Element.clearKompEvents() {
val events = getKompEvents()
for ((name, event) in getKompEvents()) {
removeEventListener(name, event)
}
events.clear()
}
internal fun Element.setKompEvent(name: String, event: (Event) -> Unit) {
val eventName: String = if (name.startsWith("on")) {
name.substring(2)
} else {
name
}
getKompEvents()[eventName] = event
this.addEventListener(eventName, event) // AddEventListenerOptions(true))
}
private val kompEvents = mutableMapOf<Element, MutableMap<String, (Event) -> Unit>>()
internal fun Element.getKompEvents(): MutableMap<String, (Event) -> Unit> {
return kompEvents.getOrPut(this) { mutableMapOf() }
}
internal fun Element.findElementIndex(): Int {
val childNodes = parentElement?.children
if (childNodes != null) {
for (index in 0 until childNodes.length) {
if (childNodes[index] == this) {
return index
}
}
}
return 0
}

View File

@@ -1,64 +0,0 @@
package nl.astraeus.komp
import org.w3c.dom.Node
import org.w3c.dom.get
data class ElementIndex(
val parent: Node,
var childIndex: Int,
var setAttr: MutableSet<String> = mutableSetOf()
) {
override fun toString(): String {
return "${parent.nodeName}[$childIndex]"
}
}
fun ArrayList<ElementIndex>.currentParent(): Node {
this.lastOrNull()?.let {
return it.parent
}
throw IllegalStateException("currentParent should never be null!")
}
fun ArrayList<ElementIndex>.currentElement(): Node? {
this.lastOrNull()?.let {
return it.parent.childNodes[it.childIndex]
}
return null
}
fun ArrayList<ElementIndex>.currentPosition(): ElementIndex? {
return if (this.size < 2) {
null
} else {
this[this.size - 2]
}
}
fun ArrayList<ElementIndex>.nextElement() {
this.lastOrNull()?.let {
it.setAttr.clear()
it.childIndex++
}
}
fun ArrayList<ElementIndex>.pop() {
this.removeLast()
}
fun ArrayList<ElementIndex>.push(element: Node) {
this.add(ElementIndex(element, 0))
}
fun ArrayList<ElementIndex>.replace(new: Node) {
if (this.currentElement() != null) {
this.currentElement()?.parentElement?.replaceChild(
new,
this.currentElement()!!
)
} else {
this.last().parent.appendChild(new)
}
}

View File

@@ -1,405 +0,0 @@
package nl.astraeus.komp
import kotlinx.browser.document
import kotlinx.html.DefaultUnsafe
import kotlinx.html.Entities
import kotlinx.html.FlowOrMetaDataOrPhrasingContent
import kotlinx.html.Tag
import kotlinx.html.TagConsumer
import kotlinx.html.Unsafe
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSpanElement
import org.w3c.dom.Node
import org.w3c.dom.asList
import org.w3c.dom.get
private var currentElement: Element? = null
interface HtmlConsumer : TagConsumer<Element> {
fun append(node: Element)
fun include(komponent: Komponent)
fun debug(block: HtmlConsumer.() -> Unit)
}
fun FlowOrMetaDataOrPhrasingContent.currentElement(): Element =
currentElement ?: error("No current element defined!")
private fun Node.asElement() = this as? HTMLElement
class HtmlBuilder(
private val komponent: Komponent?,
parent: Element,
childIndex: Int = 0
) : HtmlConsumer {
private var currentPosition = arrayListOf<ElementIndex>()
private var inDebug = false
private var exceptionThrown = false
private var currentNode: Node? = null
private var firstTag: Boolean = true
var root: Element? = null
init {
currentPosition.add(ElementIndex(parent, childIndex))
}
override fun include(komponent: Komponent) {
if (
komponent.element != null &&
!komponent.memoizeChanged()
) {
currentPosition.replace(komponent.element!!)
if (Komponent.logRenderEvent) {
println(
"Skipped include $komponent, memoize hasn't changed"
)
}
} else {
// current element should become parent
/*
val ce = komponent.element
if (ce != null) {
append(ce as Element)
}
*/
komponent.create(
currentPosition.last().parent as Element,
currentPosition.last().childIndex
)
}
currentPosition.nextElement()
}
override fun append(node: Element) {
currentPosition.replace(node)
currentPosition.nextElement()
}
override fun debug(block: HtmlConsumer.() -> Unit) {
val enableAssertions = Komponent.enableAssertions
Komponent.enableAssertions = true
inDebug = true
try {
block.invoke(this)
} finally {
inDebug = false
Komponent.enableAssertions = enableAssertions
}
}
private fun logReplace(msg: () -> String) {
if (Komponent.logReplaceEvent && inDebug) {
println(msg.invoke())
}
}
override fun onTagStart(tag: Tag) {
logReplace {
"onTagStart, [${tag.tagName}, ${tag.namespace ?: ""}], currentPosition: $currentPosition"
}
currentNode = currentPosition.currentElement()
if (currentNode == null) {
logReplace { "onTagStart, currentNode1: $currentNode" }
currentNode = if (tag.namespace != null) {
document.createElementNS(tag.namespace, tag.tagName)
} else {
document.createElement(tag.tagName)
}
logReplace { "onTagStart, currentElement1.1: $currentNode" }
currentPosition.currentParent().appendChild(currentNode!!)
} else if (
!currentNode?.asElement()?.tagName.equals(tag.tagName, true) ||
(
tag.namespace != null &&
!currentNode?.asElement()?.namespaceURI.equals(tag.namespace, true)
)
) {
logReplace {
"onTagStart, currentElement, namespace: ${currentNode?.asElement()?.namespaceURI} -> ${tag.namespace}"
}
logReplace {
"onTagStart, currentElement, replace: ${currentNode?.asElement()?.tagName} -> ${tag.tagName}"
}
currentNode = if (tag.namespace != null) {
document.createElementNS(tag.namespace, tag.tagName)
} else {
document.createElement(tag.tagName)
}
currentPosition.replace(currentNode!!)
}
currentElement = currentNode as? Element ?: currentElement
if (currentNode is Element) {
if (firstTag) {
logReplace { "Setting root: $currentNode" }
root = currentNode as Element
firstTag = false
}
currentElement?.clearKompEvents()
// if currentElement = checkbox make sure it's cleared
(currentElement as? HTMLInputElement)?.checked = false
currentPosition.lastOrNull()?.setAttr?.clear()
for (entry in tag.attributesEntries) {
currentElement!!.setKompAttribute(entry.key, entry.value)
currentPosition.lastOrNull()?.setAttr?.add(entry.key)
}
}
currentPosition.push(currentNode!!)
}
private fun checkTag(source: String, tag: Tag) {
check(currentElement != null) {
"No current tag ($source)"
}
check(currentElement?.tagName.equals(tag.tagName, ignoreCase = true)) {
"Wrong current tag ($source), got: ${tag.tagName} expected ${currentElement?.tagName}"
}
}
override fun onTagAttributeChange(
tag: Tag,
attribute: String,
value: String?
) {
logReplace { "onTagAttributeChange, ${tag.tagName} [$attribute, $value]" }
if (Komponent.enableAssertions) {
checkTag("onTagAttributeChange", tag)
}
currentElement?.setKompAttribute(attribute, value)
if (value == null || value.isEmpty()) {
currentPosition.currentPosition()?.setAttr?.remove(attribute)
} else {
currentPosition.currentPosition()?.setAttr?.add(attribute)
}
}
override fun onTagEvent(
tag: Tag,
event: String,
value: (kotlinx.html.org.w3c.dom.events.Event) -> Unit
) {
logReplace { "onTagEvent, ${tag.tagName} [$event, $value]" }
if (Komponent.enableAssertions) {
checkTag("onTagEvent", tag)
}
currentElement?.setKompEvent(event.lowercase(), value)
}
override fun onTagEnd(tag: Tag) {
logReplace {
"onTagEnd, [${tag.tagName}, ${tag.namespace}], currentPosition: $currentPosition"
}
if (exceptionThrown) {
return
}
while (currentPosition.currentElement() != null) {
currentPosition.currentElement()?.let {
it.parentElement?.removeChild(it)
}
}
if (Komponent.enableAssertions) {
checkTag("onTagEnd", tag)
}
if (currentElement != null) {
val setAttrs: Set<String> = currentPosition.currentPosition()?.setAttr ?: setOf()
// remove attributes that where not set
val element = currentElement
if (element?.hasAttributes() == true) {
for (index in 0 until element.attributes.length) {
val attribute = element.attributes[index]
if (attribute?.name != null) {
val attr = attribute.name
if (
!setAttrs.contains(attr) &&
attr != "style"
) {
element.setKompAttribute(attr, null)
}
}
}
}
}
currentPosition.pop()
currentNode = currentPosition.currentElement()
currentElement = currentNode as? Element ?: currentElement
currentPosition.nextElement()
currentElement = currentElement?.parentElement as? HTMLElement
//logReplace"onTagEnd, popped: $currentElement")
}
override fun onTagContent(content: CharSequence) {
//logReplace"onTagContent, [$content]")
check(currentElement != null) {
"No current DOM node"
}
//logReplace"Tag content: $content")
if (
currentElement?.nodeType != Node.TEXT_NODE ||
currentElement?.textContent != content.toString()
) {
currentElement?.textContent = content.toString()
}
currentPosition.nextElement()
}
override fun onTagContentEntity(entity: Entities) {
//logReplace"onTagContentEntity, [${entity.text}]")
check(currentElement != null) {
"No current DOM node"
}
val s = document.createElement("span") as HTMLSpanElement
s.innerHTML = entity.text
currentPosition.replace(
s.childNodes.asList().firstOrNull() ?: document.createTextNode(entity.text)
)
currentPosition.nextElement()
}
override fun onTagContentUnsafe(block: Unsafe.() -> Unit) {
with(DefaultUnsafe()) {
block()
val textContent = toString()
//logReplace"onTagContentUnsafe, [$textContent]")
var namespace: String? = null
if (currentPosition.currentParent().nodeType == 1.toShort()) {
val element = currentPosition.currentParent() as Element
namespace = when (Komponent.unsafeMode) {
UnsafeMode.UNSAFE_ALLOWED -> {
element.namespaceURI
}
UnsafeMode.UNSAFE_SVG_ONLY -> {
if (element.namespaceURI == "http://www.w3.org/2000/svg") {
element.namespaceURI
} else {
null
}
}
else -> {
null
}
}
}
//logReplace"onTagContentUnsafe, namespace: [$namespace]")
if (Komponent.unsafeMode == UnsafeMode.UNSAFE_ALLOWED ||
(
Komponent.unsafeMode == UnsafeMode.UNSAFE_SVG_ONLY &&
namespace == "http://www.w3.org/2000/svg"
)
) {
if (currentElement?.innerHTML != textContent) {
currentElement?.innerHTML += textContent
}
} else if (currentElement?.textContent != textContent) {
currentElement?.textContent = textContent
}
currentPosition.nextElement()
}
}
override fun onTagComment(content: CharSequence) {
//logReplace"onTagComment, [$content]")
check(currentElement != null) {
"No current DOM node"
}
currentElement?.appendChild(
document.createComment(content.toString())
)
currentPosition.nextElement()
}
fun onTagError(tag: Tag, exception: Throwable) {
exceptionThrown = true
if (exception !is KomponentException) {
val position = mutableListOf<Element>()
var ce = currentElement
while (ce != null) {
position.add(ce)
ce = ce.parentElement
}
val builder = StringBuilder()
for (element in position.reversed()) {
builder.append("> ")
builder.append(element.tagName)
builder.append("[")
builder.append(element.findElementIndex())
builder.append("]")
if (element.hasAttribute("class")) {
builder.append("(")
builder.append(element.getAttribute("class"))
builder.append(")")
}
builder.append(" ")
}
throw KomponentException(
komponent,
currentElement,
tag,
builder.toString(),
exception.message ?: "error",
exception
)
} else {
throw exception
}
}
override fun finalize(): Element {
//logReplace"finalize, currentPosition: $currentPosition")
return root ?: throw IllegalStateException(
"We can't finalize as there was no tags"
)
}
companion object {
fun create(content: HtmlBuilder.() -> Unit): Element {
val container = document.createElement("div") as HTMLElement
val consumer = HtmlBuilder(null, container)
content.invoke(consumer)
return consumer.root ?: error("No root element found after render!")
}
}
}

View File

@@ -1,272 +0,0 @@
package nl.astraeus.komp
import kotlinx.browser.window
import kotlinx.html.FlowOrMetaDataOrPhrasingContent
import org.w3c.dom.Element
import org.w3c.dom.HTMLElement
import org.w3c.dom.get
private var currentKomponent: Komponent? = null
fun FlowOrMetaDataOrPhrasingContent.currentKomponent(): Komponent =
currentKomponent ?: error("No current komponent defined! Only call from render code!")
enum class UnsafeMode {
UNSAFE_ALLOWED,
UNSAFE_DISABLED,
UNSAFE_SVG_ONLY
}
var Element.memoizeHash: String?
get() {
return getAttribute("memoize-hash")
}
set(value) {
if (value != null) {
setAttribute("memoize-hash", value.toString())
} else {
removeAttribute("memoize-hash")
}
}
abstract class Komponent {
val createIndex = getNextCreateIndex()
private var dirty: Boolean = true
var element: Element? = null
open fun create(parent: Element, childIndex: Int? = null) {
onBeforeUpdate()
val builder = HtmlBuilder(
this,
parent,
childIndex ?: parent.childNodes.length
)
try {
currentKomponent = this
builder.render()
} catch(e: KomponentException) {
errorHandler(e)
} finally {
currentKomponent = null
}
element = builder.root
updateMemoizeHash()
onAfterUpdate()
}
fun memoizeChanged() = element?.memoizeHash == null || element?.memoizeHash != fullMemoizeHash()
fun updateMemoizeHash() {
element?.memoizeHash = fullMemoizeHash()
}
private fun fullMemoizeHash(): String? {
val generated = generateMemoizeHash()
return if (generated != null) {
"${this::class.simpleName}:${generateMemoizeHash()}"
} else {
null
}
}
abstract fun HtmlBuilder.render()
/**
* This method is called after the Komponent is updated
*
* note: it's also called at first render
*/
open fun onAfterUpdate() {}
/**
* This method is called before the Komponent is updated
* and before memoizeHash is checked
*
* note: it's also called at first render
*/
open fun onBeforeUpdate() {}
fun requestUpdate() {
dirty = true
scheduleForUpdate(this)
}
/**
* Request an immediate update of this Komponent
*
* This will run immediately, make sure Komponents are not rendered multiple times
* Any scheduled updates will be run as well
*/
fun requestImmediateUpdate() {
dirty = true
runUpdateImmediately(this)
}
/**
* This function can be overwritten if you know how to update the Komponent yourself
*
* HTMLBuilder.render() is called 1st time the component is rendered, after that this
* method will be called
*
* @deprecated
*/
@Deprecated(
"Deprecated to avoid confusing with requestUpdate, use renderUpdate instead",
ReplaceWith("renderUpdate"),
level = DeprecationLevel.WARNING
)
protected fun update() {
refresh()
}
/**
* This function can be overwritten if you know how to update the Komponent yourself
*
* HTMLBuilder.render() is called 1st time the component is rendered, after that this
* method will be called
*/
open fun renderUpdate() {
refresh()
}
/**
* If this function returns a value it will be stored and on the next render it will be compared.
*
* The render will only happen if the hash is not null and has changed
*/
open fun generateMemoizeHash(): Int? = null
private fun refresh() {
val currentElement = element
check(currentElement != null) {
error("element is null")
}
val parent = currentElement.parentElement as? HTMLElement ?: error("parent is null!?")
var childIndex = 0
for (index in 0 until parent.childNodes.length) {
if (parent.childNodes[index] == currentElement) {
childIndex = index
}
}
val builder = HtmlBuilder(this, parent, childIndex)
try {
currentKomponent = this
builder.render()
} catch(e: KomponentException) {
errorHandler(e)
} finally {
currentKomponent = null
}
element = builder.root
dirty = false
}
override fun toString(): String {
return "${this::class.simpleName}"
}
companion object {
private var nextCreateIndex: Int = 1
private var updateCallback: Int? = null
private var errorHandler: (KomponentException) -> Unit = { ke ->
println("Render error in Komponent: $ke")
ke.element?.innerHTML = """<div class="komponent-error">Render error!</div>"""
window.alert("""
Error in Komponent '${ke.komponent}', ${ke.message}
Tag: ${ke.tag.tagName}
See console log for details
Position: ${ke.position}""".trimIndent()
)
}
private var scheduledForUpdate = mutableSetOf<Komponent>()
private var interceptor: (Komponent, () -> Unit) -> Unit = { _, block -> block() }
var logRenderEvent = false
var logReplaceEvent = false
var enableAssertions = false
var unsafeMode = UnsafeMode.UNSAFE_DISABLED
fun create(parent: HTMLElement, component: Komponent) {
component.create(parent)
}
fun setErrorHandler(handler: (KomponentException) -> Unit) {
errorHandler = handler
}
fun setUpdateInterceptor(block: (Komponent, () -> Unit) -> Unit) {
interceptor = block
}
private fun getNextCreateIndex() = nextCreateIndex++
private fun scheduleForUpdate(komponent: Komponent) {
scheduledForUpdate.add(komponent)
if (updateCallback == null) {
updateCallback = window.setTimeout({
runUpdate()
}, 0)
}
}
private fun runUpdateImmediately(komponent: Komponent) {
scheduledForUpdate.add(komponent)
runUpdate()
}
private fun runUpdate(): JsAny {
val todo = scheduledForUpdate.sortedBy { komponent -> komponent.createIndex }
if (logRenderEvent) {
println("runUpdate")
}
todo.forEach { next ->
interceptor(next) {
val element = next.element
if (element is HTMLElement) {
if (next.dirty) {
if (logRenderEvent) {
println("Update dirty ${next.createIndex}")
}
val memoizeHash = next.generateMemoizeHash()
if (next.memoizeChanged()) {
next.onBeforeUpdate()
next.renderUpdate()
next.updateMemoizeHash()
next.onAfterUpdate()
} else if (logRenderEvent) {
println("Skipped render, memoizeHash is equal $next-[$memoizeHash]")
}
} else {
if (logRenderEvent) {
println("Skip ${next.createIndex}")
}
}
} else {
println("Komponent element is null $next, $element")
}
}
}
scheduledForUpdate.clear()
updateCallback = null
return "JsAny".toJsString()
}
}
}

View File

@@ -1,18 +0,0 @@
package nl.astraeus.komp
import kotlinx.html.Tag
import org.w3c.dom.Element
class KomponentException(
val komponent: Komponent?,
val element: Element?,
val tag: Tag,
val position: String,
message: String,
cause: Throwable
) : RuntimeException(message, cause) {
override fun toString(): String {
return "KompException(message='$message', tag='$tag', position='$position')"
}
}

View File

@@ -1,54 +0,0 @@
package nl.astraeus.komp
inline fun <reified T> Komponent.mutableCollectionState(
initialValue: MutableCollection<T>
): MutableCollection<T> = MutableCollectionStateDelegate(
this,
initialValue
)
class MutableCollectionStateDelegate<T>(
val komponent: Komponent,
val collection: MutableCollection<T>
): MutableCollection<T> by collection {
override fun add(element: T): Boolean {
komponent.requestUpdate()
return collection.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
komponent.requestUpdate()
return collection.addAll(elements)
}
override fun clear() {
komponent.requestUpdate()
collection.clear()
}
// todo: return iterator wrapper to update at changes?
//override fun iterator(): MutableIterator<T> = collection.iterator()
override fun remove(element: T): Boolean {
komponent.requestUpdate()
return collection.remove(element)
}
override fun removeAll(elements: Collection<T>): Boolean {
komponent.requestUpdate()
return collection.removeAll(elements)
}
override fun retainAll(elements: Collection<T>): Boolean {
komponent.requestUpdate()
return collection.retainAll(elements)
}
}

View File

@@ -1,56 +0,0 @@
package nl.astraeus.komp
import kotlin.reflect.KProperty
interface Delegate<T> {
operator fun getValue(
thisRef: Any?,
property: KProperty<*>
): T
operator fun setValue(
thisRef: Any?,
property: KProperty<*>,
value: T
)
}
open class StateDelegate<T>(
val komponent: Komponent,
initialValue: T
) : Delegate<T> {
private var value: T = initialValue
init {
if (value is MutableCollection<*>) {
error("Use mutableList to create a collection!")
}
}
override operator fun getValue(
thisRef: Any?,
property: KProperty<*>
): T {
return value
}
override operator fun setValue(
thisRef: Any?,
property: KProperty<*>,
value: T
) {
if (this.value?.equals(value) != true) {
this.value = value
komponent.requestUpdate()
}
}
}
inline fun <reified T> Komponent.state(
initialValue: T
): Delegate<T> = StateDelegate(
this,
initialValue
)