Skip to content
This repository has been archived by the owner on Aug 10, 2024. It is now read-only.

Commit

Permalink
Make value synchronization more robust and fix bug (#422)
Browse files Browse the repository at this point in the history
  • Loading branch information
sanity authored Dec 26, 2022
1 parent d865dc0 commit 804ca64
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 132 deletions.
20 changes: 20 additions & 0 deletions api/kweb-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,26 @@ public final class kweb/ValueElement$DiffData$Companion {
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}

public final class kweb/ValueElement$LastModificationSource : java/lang/Enum {
public static final field Browser Lkweb/ValueElement$LastModificationSource;
public static final field Server Lkweb/ValueElement$LastModificationSource;
public static fun valueOf (Ljava/lang/String;)Lkweb/ValueElement$LastModificationSource;
public static fun values ()[Lkweb/ValueElement$LastModificationSource;
}

public final class kweb/ValueElement$Value {
public fun <init> (Ljava/lang/String;Lkweb/ValueElement$LastModificationSource;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Lkweb/ValueElement$LastModificationSource;
public final fun copy (Ljava/lang/String;Lkweb/ValueElement$LastModificationSource;)Lkweb/ValueElement$Value;
public static synthetic fun copy$default (Lkweb/ValueElement$Value;Ljava/lang/String;Lkweb/ValueElement$LastModificationSource;ILjava/lang/Object;)Lkweb/ValueElement$Value;
public fun equals (Ljava/lang/Object;)Z
public final fun getLastModificationSource ()Lkweb/ValueElement$LastModificationSource;
public final fun getValue ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public abstract class kweb/ViewportHeight {
public abstract fun getValue ()Ljava/lang/String;
}
Expand Down
15 changes: 15 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
import java.net.URL

plugins {
Expand Down Expand Up @@ -74,6 +75,20 @@ dependencies {
testImplementation("org.awaitility:awaitility:4.2.0")
}

tasks.test {
testLogging {
events("failed")

showExceptions = true
exceptionFormat = FULL
showCauses = true
showStackTraces = true

showStandardStreams = false
}
}


tasks.dokkaHtml {
dokkaSourceSets {
configureEach {
Expand Down
169 changes: 169 additions & 0 deletions src/main/kotlin/kweb/ValueElement.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package kweb

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kweb.ValueElement.LastModificationSource.Browser
import kweb.ValueElement.LastModificationSource.Server
import kweb.html.events.Event
import kweb.state.CloseReason
import kweb.state.KVal
import kweb.state.KVar
import kweb.state.ReversibleFunction
import kweb.util.json

/**
* Abstract class for the various elements that have a `value` attribute and which support `change` and `input` events.
*
* @param kvarUpdateEvent The [value] of this element will update on this event, defaults to [input](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event)
*/
abstract class ValueElement(
open val element: Element, val kvarUpdateEvent: String = "input",
val initialValue: String? = null
) : Element(element) {
val valueJsExpression: String by lazy { "document.getElementById(\"$id\").value" }

suspend fun getValue(): String {
return when (val result =
element.browser.callJsFunctionWithResult("return document.getElementById({}).value;", id.json)) {
is JsonPrimitive -> result.content
else -> error("Needs to be JsonPrimitive")
}
}

//language=JavaScript
fun setValue(newValue: String) {
element.browser.callJsFunction(
"""
const element = document.getElementById({});
element.value = {};
delete element.dataset.previousInput;
""".trimIndent(),
element.id.json, newValue.json
)
}

fun setValue(newValue: KVal<String>) {
val initialValue = newValue.value
setValue(initialValue)
val listenerHandle = newValue.addListener { _, new ->
setValue(new)
}
element.creator?.onCleanup(true) {
newValue.removeListener(listenerHandle)
}
}

data class Value(val value: String, val lastModificationSource: LastModificationSource)
enum class LastModificationSource {
Server, Browser
}

private var _valueKvar: KVar<Value>? = null

private lateinit var _stringValueKvar: KVar<String>


/**
* A KVar bidirectionally synchronized with the [value of a select element](https://www.w3schools.com/jsref/prop_select_value.asp).
* This [KVar] will update if the select element is changed (depending on [kvarUpdateEvent]), and will modify
* the element value if the KVar is changed.
*
* [value] can be set to a `KVar<String>` to synchronize with an existing KVar, or it will create a new `KVar("")`
* if not set.
*/
var value: KVar<String>
get() {
if (_valueKvar == null) {
synchronized(this) {
_valueKvar = KVar(Value(initialValue ?: "", Server))
_stringValueKvar =
_valueKvar!!.map(object : ReversibleFunction<Value, String>("ValueElement.value") {
override fun invoke(from: Value): String = from.value

override fun reverse(original: Value, change: String): Value =
Value(change, Server)

})
this.creator?.onCleanup(true) {
value.close(CloseReason("Parent element closed"))
}
attachListeners(_valueKvar!!)
updateKVar(_valueKvar!!, updateOn = kvarUpdateEvent)
}
}
return _stringValueKvar
}
set(v) {
if (_valueKvar != null) error("`value` may only be set once, and cannot be set after it has been retrieved")
synchronized(this) {
setValue(v.value)
_stringValueKvar = v
_valueKvar = _stringValueKvar.map(object : ReversibleFunction<String, Value>("ValueElement.value") {
override fun invoke(from: String): Value = Value(from, Server)

override fun reverse(original: String, change: Value): String = change.value

})
attachListeners(_valueKvar!!)
updateKVar(_valueKvar!!, updateOn = kvarUpdateEvent)
}
}

private fun attachListeners(kv: KVar<Value>) {
val handle = kv.addListener { _, value ->
// Only update the DOM element if the source of the change was the server
if (value.lastModificationSource == Server) {
setValue(value.value)
}
}
element.creator?.onCleanup(true) {
kv.removeListener(handle)
}
}

/**
* Automatically update `toBind` with the value of this INPUT element when `updateOn` event occurs.
*/

@Serializable
data class DiffData(val prefixEndIndex: Int, val postfixOffset: Int, val diffString: String)

private fun applyDiff(oldString: String, diffData: DiffData): String {

val newString = when {
diffData.postfixOffset == -1 -> {//these 2 edge cases prevent the prefix or the postfix from being
// repeated when you append text to the beginning of the text or the end of the text
oldString.substring(0, diffData.prefixEndIndex) + diffData.diffString
}

diffData.prefixEndIndex == 0 -> {
diffData.diffString + oldString.substring(oldString.length - diffData.postfixOffset)
}

else -> {
oldString.substring(0, diffData.prefixEndIndex) + diffData.diffString +
oldString.substring(oldString.length - diffData.postfixOffset)
}
}
return newString
}

private fun updateKVar(toBind: KVar<Value>, updateOn: String = "input") {
on(
//language=JavaScript
retrieveJs = "get_diff_changes(document.getElementById(\"${element.id}\"))"
)
.event<Event>(updateOn) {
//TODO, this check shouldn't be necessary. It should be impossible for get_diff_changes() to return a null,
//but we had a null check previously, so I went ahead and added it.
if (it.retrieved != JsonNull) {
val diffDataJson = it.retrieved
val diffData = Json.decodeFromJsonElement(DiffData.serializer(), diffDataJson)
toBind.value = Value(applyDiff(toBind.value.value, diffData), Browser)
}
}
}

}
131 changes: 4 additions & 127 deletions src/main/kotlin/kweb/prelude.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package kweb

import io.ktor.server.routing.*
import io.mola.galimatias.URL
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonPrimitive
import kweb.html.HeadElement
import kweb.html.TitleElement
import kweb.html.events.Event
import kweb.html.fileUpload.FileFormInput
import kweb.routing.PathTemplate
import kweb.routing.RouteReceiver
Expand Down Expand Up @@ -552,130 +553,6 @@ fun ElementCreator<Element>.label(
}
}

/**
* Abstract class for the various elements that have a `value` attribute and which support `change` and `input` events.
*
* @param kvarUpdateEvent The [value] of this element will update on this event, defaults to [input](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event)
*/
abstract class ValueElement(
open val element: Element, val kvarUpdateEvent: String = "input",
val initialValue: String? = null
) : Element(element) {
val valueJsExpression: String by lazy { "document.getElementById(\"$id\").value" }

suspend fun getValue(): String {
return when (val result =
element.browser.callJsFunctionWithResult("return document.getElementById({}).value;", id.json)) {
is JsonPrimitive -> result.content
else -> error("Needs to be JsonPrimitive")
}
}

//language=JavaScript
fun setValue(newValue: String) {
element.browser.callJsFunction(
"document.getElementById({}).value = {};",
element.id.json, newValue.json
)
}

fun setValue(newValue: KVal<String>) {
val initialValue = newValue.value
setValue(initialValue)
val listenerHandle = newValue.addListener { _, new ->
setValue(new)
}
element.creator?.onCleanup(true) {
newValue.removeListener(listenerHandle)
}
}

@Volatile
private var _valueKvar: KVar<String>? = null

/**
* A KVar bidirectionally synchronized with the [value of a select element](https://www.w3schools.com/jsref/prop_select_value.asp).
* This [KVar] will update if the select element is changed (depending on [kvarUpdateEvent]), and will modify
* the element value if the KVar is changed.
*
* [value] can be set to a `KVar<String>` to synchronize with an existing KVar, or it will create a new `KVar("")`
* if not set.
*/
var value: KVar<String>
get() {
synchronized(this) {
if (_valueKvar == null) {
value = KVar(initialValue ?: "")
this.creator?.onCleanup(true) {
value.close(CloseReason("Parent element closed"))
}
attachListeners(value)
}
}
return _valueKvar!!
}
set(v) {
if (_valueKvar != null) error("`value` may only be set once, and cannot be set after it has been retrieved")
updateKVar(v, updateOn = kvarUpdateEvent)
attachListeners(v)
setValue(v.value)
_valueKvar = v
}

private fun attachListeners(kv : KVar<String>) {
val handle = kv.addListener { _, newValue ->
setValue(newValue)
}
element.creator?.onCleanup(true) {
kv.removeListener(handle)
}
}

/**
* Automatically update `toBind` with the value of this INPUT element when `updateOn` event occurs.
*/

@Serializable
data class DiffData(val prefixEndIndex: Int, val postfixOffset: Int, val diffString: String)

private fun applyDiff(oldString: String, diffData: DiffData): String {

val newString = when {
diffData.postfixOffset == -1 -> {//these 2 edge cases prevent the prefix or the postfix from being
// repeated when you append text to the beginning of the text or the end of the text
oldString.substring(0, diffData.prefixEndIndex) + diffData.diffString
}

diffData.prefixEndIndex == 0 -> {
diffData.diffString + oldString.substring(oldString.length - diffData.postfixOffset)
}

else -> {
oldString.substring(0, diffData.prefixEndIndex) + diffData.diffString +
oldString.substring(oldString.length - diffData.postfixOffset)
}
}
return newString
}

private fun updateKVar(toBind: KVar<String>, updateOn: String = "input") {
on(
//language=JavaScript
retrieveJs = "get_diff_changes(document.getElementById(\"${element.id}\"))"
)
.event<Event>(updateOn) {
//TODO, this check shouldn't be necessary. It should be impossible for get_diff_changes() to return a null,
//but we had a null check previously, so I went ahead and added it.
if (it.retrieved != JsonNull) {
val diffDataJson = it.retrieved
val diffData = Json.decodeFromJsonElement(DiffData.serializer(), diffDataJson)
toBind.value = applyDiff(toBind.value, diffData)
}
}
}

}

/******************************
* Route extension
******************************/
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/kweb/kweb_bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ function get_diff_changes(htmlInputElement) {

savePreviousInput(newString, htmlInputElement)//put the newString into the data attribute so it can be used as the oldString the next time this method is run

if (oldString == undefined) {//the first time this is run previous-input should be undefined so we just return the new string
if (oldString === undefined) {//the first time this is run previous-input should be undefined so we just return the new string
return new DiffPatchData(0, 0, newString);
}
let commonPrefixEnd = 0;
Expand Down
3 changes: 0 additions & 3 deletions src/test/kotlin/kweb/SelectValueTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,6 @@ class SelectValueTestApp {
option().set("value", "cat").text("Cat")
}
selectValue = select.value
selectValue.addListener { old, new ->
println("$old -> $new")
}
}
}
}
Loading

0 comments on commit 804ca64

Please sign in to comment.