Skip to content

Version 0.14

Compare
Choose a tag to compare
released this 17 Jan 09:08
· 26 commits to release/0.14 since this release

Important Notes for Apple Users

  • If you use an Apple computer with Apple silicon architecture please make sure to use a JDK targeted to this architecture. It is officially named aarch64 (see JEP 391). Starting with Java 17 all major distributors of JDKs should support this.
  • You will need the following piece of code in your build.gradle.kts:
    rootProject.plugins.withType<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin> {
        rootProject.the<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension>().nodeVersion = "16.0.0"
    }
    This is already included in the current version of our fritz2-template project.
    This workaround should be needed only until Kotlin version 1.6.20

Breaking Changes

PR #544: Rework RenderContext

  • RenderContext is now an Interface offering all functions to render both dynamic and static content
  • A Tag is an implementation of this interface which adds everything needed to handle a DOM-element
  • TagContext has been removed
  • render*-methods now by default add a new Div-Tag into the DOM which acts as the mountpoint. Those divs are marked with the attribute data-mount-point and the static default class mount-point. The latter simply sets the display: contents rule in order to hide this element from the visual rendering:
    data.render {
        // render something
    }
    will result in the following HTML structure:
    <div class="mount-point" data-mount-point=""
        <!-- your dynamic content -->
    </div>
  • render*-methods in RenderContext now offer a new parameter into which can be used to bypass the creation of a default parent Div-tag as described above. If there is already an element which could serve as mountpoint, simply pass its Tag<HTMLElement> as the into parameter. Rule of thumb: Just pass this, since the call to render should appear directly below the desired parent element in most cases:
    ul { // this == Tag<Ul>
        items.data.render(into = this) {
        //                ^^^^^^^^^^^
        //                pass the parent tag to use it as mount-point
            li { +it }
        }
    }
    This results in the following DOM:
    <ul data-mount-point=""> // no more CSS-class, as the tag *should* appear on screen in most cases
        <li>...</li>
        ...
        <li>...</li>
    </ul>
    
    But beware: the mount-point controls its siblings, meaning that all other content will be removed by every update of the flow. So never apply the into = this pattern if you want to render multiple different flows into a parent, or a mixture of dynamic and static content.
  • instead of asText use renderText. It will create a Span-Tag as parent element and a child text-node for the text. Analogous to the render*-methods of RenderContext, it also accept the into = this parameter, which directly renders the text-node into the parent provided without creating the extra <span>.

PR #559: Apply KSP as substitute for KAPT for automatic lenses generation

Lenses are still automatically created by the @Lenses annotation for data classes, but a companion object must be declared even though it might be left empty. The new processor now supports generic data classes as a new feature.

The API for accessing a lens has changed:

  • there is no L-object holding all lenses for all domain types anymore
  • the lens is now created by a factory function instead by a property

For accessing a lens, consider the following example:

// somewhere in commonMain
@Lenses
data class Language(
    val name: String,
    val supportsFP: Boolean
) {
    companion object // important to declare - KSP can't create this
}

// accessing code - could also be in jsMain
val language = storeOf(Language("Kotlin", true))
// old: val name = kotlin.sub(L.Language.name)
val name = language.sub(Language.name())

The lenses are created as extension functions of the companion object, so no dedicated object is needed. We believe this to be more comfortable to work with: When using a lens, the domain type is already present, so this should be intuitive. The L-object wasn't too complex either, but it seemed a bit "magical" to new users, and a bit artificially named.

To get the lens, just call the function named exactly like the corresponding property.

This introduces one restriction to the design of a custom implemented companion object: You are not allowed to implement such a function yourself. The name is required by the processor and defiance will lead to an expressive compilation error.

Migration guide

Tweak buildfile

The following changes must be applied to the build.gradle.kts

plugins {
    // Kotlin 1.6.x version
    kotlin("multiplatform") version "1.6.10"
    // Add KSP support
    id("com.google.devtools.ksp") version "1.6.10-1.0.2"
    // Remove fritz2-plugin
}

// Add further settings for KSP support:
dependencies {
    add("kspMetadata", "dev.fritz2:lenses-annotation-processor:$fritz2Version")
}
kotlin.sourceSets.commonMain { kotlin.srcDir("build/generated/ksp/commonMain/kotlin") }
tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().all {
    if (name != "kspKotlinMetadata") dependsOn("kspKotlinMetadata")
}
// Needed to work on Apple Silicon. Should be fixed by 1.6.20 (https://youtrack.jetbrains.com/issue/KT-49109#focus=Comments-27-5259190.0-0)
rootProject.plugins.withType<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin> {
    rootProject.the<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension>().nodeVersion = "16.0.0"
}
Code Migration

Migrating the code can be done quite easily and reliably with simple regular expressions via search and replace:

  • remove all imports of the L-object:
    • search: ^import .*\.L$
    • replace: by nothing
  • change invocation of the lens:
    • search: L\.([\w\.]+) (with activated case sensitivity!)
    • replace: $1\(\)

There is one trap you don't want to step in when replacing via regexp: If you have a receiver that is or ends with a big "L", this will be mistakenly removed:

class Foo<L> {
    fun L.doSomething() = ... // The regexp will change this line as well
}

There could be more false positives we have not encountered yet, so watch out for compiler errors after applying those regexps.

Further Cross-functional Constraints

  • fritz2 now requires Kotlin 1.6.10 in order to run
  • fritz2 now supports all Java versions again (up to 17 right now)

PR #543: Router is now a Store

Internally, a Router is now a Store, so you can use everything a Store offers and create your own Handlers for your Router instance (compared to RootStore):

object MyRouter : MapRouter(mapOf("page" to "overview")) {

    val overview = handle {
        it + ("page" to "overview")
    }

    val details = handle<String> { route, id ->
        route + mapOf("page" to "details", "detailsId" to id)
    }
}


// Navigate to overview page
clicks handledBy MyRouter.overview
// Navigate to details page with detailsId=12
clicks.map { "12" } handledBy MyRouter.details

Smaller API breaking changes

  • PR #560: Removed deprecated uniqueId() function → use Id.next() instead

New Features

PR #556: Improve Shortcut Handling of KeyboardEvents

This PR improves the handling of shortcuts when dealing with KeyboardEvents:

  • remove Key-class
  • add improved Key-class named Shortcut based upon a new concept of Modifier-interface which enables constructing shortcuts, e.g. "Strg + K" or "Shift + Alt + F".
  • add improved Keys-object with predefined common keys like Tab, Enter, also add the modifier keys like Alt, Shift, Meta and Control
  • mark the special interest functions key() as deprecated in favor of relying on standard flow functions like filter or map.

The new shortcut API allows easy combination of shortcuts with modifier shortcuts, constructing those from a KeyboardEvent, and also prevents meaningless combinations of different shortcuts:

// Constructing a shortcut by hand
Shortcut("K") 
// -> Shortcut(key = "K", ctrl = false, alt = false, shift = false, meta = false)

// Or use factory function:
shortcutOf("K")

// Set modifier states, need to use constructor:
Shortcut("K", ctrl = true) // Shortcut(key= "K", ctrl = true, alt = false, shift = false, meta = false)

// Constructing a shortcut from a KeyboardEvent
div {
    keydowns.map { shortcutOf(it) } handledBy { /* use shortcut-object for further processing */ }
    //                        ^^
    //                        use KeyboardEvent to construct a Shortycut-object with all potentially 
    //                        modifier key states reflected!
}

// Using predefined shortcuts from Keys object
Keys.Enter // named-key for the enter key stroke, is a `Shortcut`
Keys.Alt // `ModifierShortcut` -> needs to be combined with a "real" shortcut in order to use it for further processing
// The same but more cumbersome and prone to typos
Shortcut("Enter")
// Not the same (!)
Shortcut("Alt") // -> Shortcut(key= "Alt", ..., alt = false)
Keys.Alt // -> ModifierKey-object with alt = true property!

// Constructing a shortcut with some modifier shortcuts
Shortcut("K") + Keys.Control
// Same result, but much more readable the other way round:
Keys.Control + "K"

// Defining some common combination:
val searchKey = Keys.Control + Keys.Shift + "F"
//              ^^^^^^^^^^^^
//              You can start with a modifier shortcut.
//              Appending a String to a ModifierKey will finally lead to a `Shortcut`.

val tabbing = setOf(Keys.Tab, Keys.Shift + Keys.Tab)

// API prevents accidently usage: WON'T COMPILE because real shortcuts can't be combined
Shortcut("F") + Shortcut("P") 

// Shortcut is a data class → equality is total:
Keys.Control + Keys.Shift + "K" == Shortcut("K", shift = true, ctrl= true, alt = false, meta = false)
// But
Keys.Control + Keys.Shift + "K" != Shortcut("K", shift = false, ctrl= true, alt = false, meta = false)
//             ^^^^^^^^^^                        ^^^^^^^^^^^^^
//                 +-----------------------------------+

// Case sensitive, too. Further impact is explained in next section.
shortcutOf("k") != shortcutOf("K")

Be aware of the fact that the key-property is taken from the event as it is. This is important for all upper case keys: The browser will always send an event with shift-property set to true, so in order to match it, you must construct the matching shortcut with the Shift-Modifier:

// Goal: Match upper case "K" (or to be more precise: "Shift + K")
keydowns.events.filter { shortcutOf(it) == shortcutOf("K") } handledBy { /* ... */ }
//                       ^^^^^^^^^^^^^^    ^^^^^^^^^^^^^^^
//                       |                 Shortcut(key = "K", shift = false, ...)
//                       |                                     ^^^^^^^^^^^^
//                       |                                 +-> will never match the event based shortcut!
//                       |                                 |   the modifier for shift needs to be added!
//                       Shortcut("K", shift = true, ...)--+
//                       upper case "K" is (almost) always send with enabled shift modifier!

// Working example
keydowns.events.filter { shortcutOf(it) == Keys.Shift + "K" } handledBy { /* ... */ }

Since most of the time you will be using the keys within the handling of some KeyboardEvent, there are some common patterns relying on the standard Flow functions like filter, map or mapNotNull to apply:

// All examples are located within some Tag<*> or WithDomNode<*>

// Pattern #1: Only execute on specific shortcut:
keydowns.events.filter { shortcutOf(it) == Keys.Shift + "K"}.map { /* further processing if needed */ } handledBy { /* ... */ }

// Variant of #1: Only execute the same for a set of shortcuts:
keydowns.events.filter { setOf(Keys.Enter, Keys.Space).contains(shortcutOf(it)) }.map { /* further processing if needed */ } handledBy { /* ... */ }

// Pattern #2: Handle a group of shortcuts with similar tasks (navigation for example)
keydowns.events.mapNotNull{ event -> // better name "it" in order to reuse it
    when (shortcutOf(event)) {
        Keys.ArrowDown -> // create / modify something to be handled
        Keys.ArrowUp -> // 
        Keys.Home -> // 
        Keys.End -> // 
        else -> null // all other key presses should be ignored, so return null to stop flow processing!
    }.also { if(it != null) event.preventDefault() // page itself should not scroll up or down! }
    //          ^^^^^^^^^^
    //          Only if a shortcut was matched
} handledBy { /* ... */ }

(The final result is based upon the PR #565 too)

PR #558: Add Middleware support to html API + pre-build stateless Authentication middleware

In the fritz2 http API you can now add Middlewares which have the following definition:

interface Middleware {
    suspend fun enrichRequest(request: Request): Request
    suspend fun handleResponse(response: Response): Response
}

You can add a Middleware to all your http calls by using the new use(middleware: Middleware) function.

val logging = object : Middleware {
    override suspend fun enrichRequest(request: Request): Request {
        console.log("Doing request: $request")
        return request
    }

    override suspend fun handleResponse(response: Response): Response {
        console.log("Getting response: $response")
        return response
    }
}

val myAPI = http("/myAPI").use(logging)
...

You can add multiple Middlewares in one row with .use(mw1, mw2, mw3). The enrichRequest functions will be called from left to right (mw1, mw2, mw3), the handleResponse functions from right to left (mw3, mw2, mw1). You can stop the processing of a Middleware's Response further down the chain with return response.stopPropagation().

Also, we built a pre-implemented Authentication middleware to enrich all request with authentication information and handle bad authentication responses in general:

abstract class Authentication<P> : Middleware {

    // List of status codes forcing authentication
    open val statusCodesEnforcingAuthentication: List<Int> = listOf(401, 403)

    // Add your authentication information to all your request (e.g. append header value) 
    abstract fun addAuthentication(request: Request, principal: P?): Request

    // Start your authentication process (e.g. open up a login modal)
    abstract fun authenticate()

    val authenticated: Flow<Boolean>
   
    val principal: Flow<P?>
}

For more information have a look at the docs.

PR #544: Rework RenderContext

The nearest MountPoint is now available on scope when rendering (some convenience methods to access it). It allows the registration of lifecycle-handlers after mounting and before unmounting a Tag from the DOM:

div {
    afterMount(someOptionalPayload) { tag, payload ->
        // Do something here
    }

    beforeUnmount(someOptionalPayload) { tag, payload ->
        // Do something here
    }   
}

This new feature is used for the transition support (see next item).

PR #545: Transitions (css-animations on DOM manipulation)

This PR adds transition-support to fritz2. You now can define a css-transition by the css classes describing the transition itself as well as the start- and endpoint like..

// CSS is tailwindcss (https://tailwindcss.com)
val fade = Transition(
    enter = "transition-all duration-1000", 
    enterStart = "opacity-0",
    enterEnd = "opacity-100",
    leave = "transition-all ease-out duration-1000",
    leaveStart = "opacity-100",
    leaveEnd = "opacity-0"
)

..and apply it to a tag:

div {
    inlineStyle("margin-top: 10px; width: 200px; height: 200px; background-color: red;").   
    transition(fade)
}

Recommendation: Do not apply transitions to tags that are also styled dynamically (Flow based). This might introduce race conditions and therefore unwanted visual effects.

Improvements

  • PR #564: Provide annex context for creating elements as direct sibling of a tag

Fixed Bugs

  • PR #557: Removed touchends-event scrolling from appFrame component (fixed jumping screen problem)
  • PR #546: Use a SharedFlow for Web Component attribute changes (Many thanks to @tradeJmark for his great work!)