Releases: jwstegemann/fritz2
Version 1.0-RC15
Improvements
The most important change for the project is the upgrade to the latest Kotlin version, that is 1.9.22 at the time of this release and gradle 8.5.
- PR #789: Kotlin 1.9.20 & refreshVersions plugin
- PR #841: Refresh Versions for upcoming RC13 Release (Kotlin 1.9.22 for example)
- PR #845: Update Gradle to v8.5
- PR #811: Move docs for new serialization module to docs sites
- PR #817: Update the headless documentation of portalling
- PR #819: Make RootStore.runWithJob public
- PR #822: Improves PopUpPanel Rendering
- PR #823: Improves and enhances the reactive styling
- PR #824: Improve event handling in dataCollection
- PR #826: Improves the validation message filtering and resets the default behaviour
- PR #829: Add Scope handling for Portal Containers
- PR #830, #834: Avoid events in flatMapLatest
- PR #835: Improves PopUpPanel
- PR #836: Improves TicTacToe example
- PR #838: Add UI-Tests to GitHub Build-Process
- PR #839: Add documentation for Stores current property
Fixed Bugs
- PR #847: Fix implicit dependency error in Gradle for publishing
- PR #843: Fix javadoc jar publication for publishing to MavenCentral
- PR #842: Fix Selection Behaviour of ListBox
- PR #812: Fixes WebComponent example
- PR #833: Fix nested Portals
- PR #837: Fix Mountpoint in Portals
- PR #838: Fix some regression in DataCollection
Credits
Special thanks to @jillesvangurp for his amazing work on #789!
Version 1.0-RC12
Improvements
- PR #790: Query parameters for GET
- PR #808: Make
ComponentValidationMessage
a data class - PR #804: Removes portal stylings
Fixed Bugs
PR #806 - Proper management of Store Lifecycle (Jobs, Handlers and Flows)
Motivation
Currently there is no real lifecycle-management for Jobs
of Handler
s and Stores
. The initial idea was to hide those details from the user in order to make the API and overall user-experience as easy and pleasant as possible.
Sadly this was a bad idea - not only does it lead to memory leaks, but also to unexpected behaviour due "handledBy" invocations running endlessly. Imagine a delayed change of a store's data initiated by a handler which is no longer "visible", because the Mountpoint
in which the handler was called was already destroyed. This is definitely a problem, as it contradicts the reliable reactive behaviour of fritz2.
That being said, especially the latter problem arises very seldomly, which is the reason why we encountered and subsequently adressed this problem so late on the final route to 1.0 version of fritz2.
Nevertheless we have to fix it, so here we go.
Conclusion
There is a need for an explicit lifecycle-management for all fritz2 elements which are related to reactive behaviour such as:
Store
sHandler
s- (
History
- more or less for streamlining reasons. Appears rarely outside of aStore
oderWithJob
, so there are no behaviour changes) - (
Router
- just for streamlining reasons. Appears only once inside an application, so there are no behaviour changes)
Within a RenderContext
, those elements should rely on the managed Job
of their context to be finished, as the RenderContext
gets dropped by any render*
-function. This solves the memory leaks as well as the unexpected behaviour of a neverending local store or handledBy
call.
Outside of any RenderContext
or other receivers that offer some Job
, the job-handling should be made explicit. Typically this applies for global stores that hold application states. Those elements are discrete and rather small in number and are intended to run for the whole lifecycle of an application, so there is no harm in creating new jobs for those cases.
Solution
RootStores
Previously, RootStores created their own jobs which were never cancelled. With this PR, every store initialization needs a job: Job
parameter.
class RootStore<D>(initialData: D, **job: Job**, override val id: String = Id.next())
fun <D> storeOf(initialData: D, **job: Job**, id: String = Id.next())
Because the jobs within a RenderContext
are already managed, we also added convenience functions which directly use the RenderContext
-job if a store is created with the storeOf
-factory:
// `WithJob` is a base interface of `RenderContext`
fun <D> WithJob.storeOf(initialData: D, job: Job = this.job, id: String = Id.next())
// ^^^^^^^^
// the job is taken from its receiver context
Each store which is created outside of a WithJob
-Scope or directly using the constructor needs a user managed Job
. The user himself is responsible to take care of the job lifecycle and cancelling the job when it is not needed anymore. We suggest using the job of the surrounding RenderContext, because that one is always properly managed.
In real world applications, it is normal to have some globally defined Store
s. Don't be afraid to simply create new Job
s for those without any "management" by yourself. Those stores are intended to run for as long as the application runs. So there is no need to stop or cancel them.
Forever running jobs inside such global stores were a normal occurance before this PR and will remain so after this PR.
No more global handledBy
Previously, handledBy
could be called everywhere. In a WithJob
-context (e.g. a RenderContext
), it used that job, otherwise it created a new job which was endlessly running within the application-lifecycle, which is what might have caused some unexpected side effects.
Now you can only run handledBy
within
WithJob
-scope (aRenderContext
implementsWithJob
)- Within a
RootStore
-Instance
This ensures that the Job
used inside of handledBy
is always properly managed, which includes both cases:
- it gets cancelled by reactive rendering changes
- it runs forever because it is located inside a global
Store
which is intended to run forever
The 'handledBy'-functions within a RootStore
are protected
and can therefore only be used within the RootStore
itself or in any derived custom store-implementation. A RootStore
as receiver - e.g. using extension functions or apply/run - is not sufficient!
If you explicitely want to use the store-job outside the RootStore
, you have to create an extension function with the WithJob
receiver and call that function within the RootStore
wrapped with the new runWithJob
-function.
Example:
object MyStore : RootStore<String>("", Job()){
init {
runWithJob{ myFunction() }
}
}
fun WithJob.myFunction() {
flowOf("ABC") handledBy MyStore.update
}
Alongside with this change, a handledBy
-call will also be interrupted if:
- The store-job has been cancelled
- The consumer-job (job of the scope where
handledBy
was called) has been cancelled
Also, the store's data
-Flow will be completed when the store-job has been cancelled to prevent further side effects of the store.
Improve Rendering
When using reactive rendering, e.g. using Flow<T>.render
or Flow<T>.renderEach
, the render-operation was previously never interrupted if a new flow-value was emitted. The render-operations where queued after each other. This behaviour has changed with this PR. When the Flow
emits a new value, the current render-task will be interrupted and it will directly re-render the content with the latest value. This will improve performance.
In rare circumstances, this might also cause different behaviour. In our opinion this could only happen when the reactive rendering is abused for state handling outside of Store
s, for example with mutating some var foo
outside of the rendering block. Since we consider such implementations bad practise, we recommend to change these constructs.
Migration Guide
Stores
For all global Store
s of an application, just add some newly created Job
:
// previously
object MyApplicationStore : RootStore<AppState>(AppState(...)) {
}
// now
object MyApplicationStore : RootStore<AppState>(AppState(...), job = Job()) {
}
// previously
val storedAppState = storeOf(AppState())
// now
val storedAppState = storeOf(AppState(), job = Job())
If you encounter a compiler error due to missing job
-parameter inside a RenderContext
, please have a look at the last section about "Chasing Memory Leaks" where the dangers and solutions as explained in depth.
Global handledBy Calls
You simply have to move those calls inside some Store
or some RenderContext
- there is no standard solution which fits all situations. You have to decide for yourself where the data handling fits best.
If you have two stores and changes to one should also change the other, consider handleAndEmit
as alternate approach or move the call to the dependent store (the one that holds the handler passed to handledBy
).
History
The history
-function without receiver has been removed. So you either have to move the history code inside some Store
(which is the common and recommended approach) or you have to invoke the constructor manually:
// previously
val myHistory = history()
// now
object MyStore : RootStore<MyData>(MyData(), job = Job()) {
val history = history()
}
// or if really needed outside a store:
val myHistory = History(0, emptyList(), Job())
Router
If you really need to call the Router
-constructors manually, you have to provide a Job
now:
// previously
val router = Router(StringRoute(""))
// now
val router = Router(StringRoute(""), job = Job())
Consider using the routerOf
-factories, they will create a Job
automatically.
Chasing Memory Leaks
Besides the previously shown dedicated proposals for fixing compiler errors, we encourage you to scan your code for potential memory-issues we simply cannot prevent by our framework:
Imagine the creation of a Store
inside a RenderContext
where a new Job()
is created:
// previously
fun RenderContext.renderSomething() = div {
object MyApplicationStore : RootStore<AppState>(AppState(...)) {
}
}
// now - satisfies compiler but is wrong!
fun RenderContext.renderSomething() = div {
object MyApplicationStore : RootStore<AppState>(AppState(...), job = Job()) {
// ^^^^^^^^^^^
// This fixes the compiler error, but in most cases
// this is *wrong*. The job-object is never cancelled
// -> memory leak!
}
}
// now - recommended approach
fun RenderContext.renderSomething() = div {
object MyApplicationStore : RootStore<AppState>(AppState(...), job = this@renderSomething.job) {
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Reuse job of the surrounding RenderContext
// and you will be fine.
}
}
Last but not least: If you...
Version 1.0-RC11
Version 1.0-RC10
Fixed Bugs
- PR #793: Fix reactively nested
beforeUnmount
Calls
Version 1.0-RC9
Fixed Bugs
- PR #792: Semove
setFocus
from PopUpPanel
Version 1.0-RC8
Improvements
PR #783 / PR #788 - New utility render-functions for RenderContext
PR #783 and PR #788 add new utility render
-functions inside RenderContext
. @serras initially brought up the idea and implementations to reduce boilerplate code by adding some convenience render-functions for common UI-patterns.
Motivation
Often only some specific state of the data model should be rendered, while for other states the UI artefact should disappear. Typical cases are nullable types, where only for the none null
part something should be rendered. The following new functions are added to the fritz2's core to adress those cases.
Please regard: If you ever need to render some UI also for the other state-variants, then just refer to the standard render
-function and use explicit case analysis like if-else
or when
expressions inside the render-block.
New Functions
The renderIf(predicate: (V) -> Boolean)
function only renders the given content
, when the given predicate
returns true
for the value inside the Flow<V>
.
val store = storeOf<String>("foo")
render {
store.data.renderIf(String::isNotBlank) {
p { +it }
}
}
The renderNotNull()
function only renders the given content
, when the value inside the Flow<V?>
is not null.
val store = storeOf<String?>(null)
render {
store.data.renderNotNull {
p { +it }
}
}
The renderIs(klass: KClass<W>)
function only renders the given content
, when the value inside the Flow<V>
is a type of klass
.
interface I
class A: I
class B: I
val store = storeOf<I>(A())
render {
store.data.renderIs(A::class) {
p { +"A" }
}
}
PR #781: Migrate popper.js to Floating-UI and add Portalling
This PR adresses two closely related aspects within our headless components:
- migrate outdated popper.js to the newer Floating-UI (see https://floating-ui.com/docs/migration)
- introduce a portalling mechanism to prevent overlays beeing clipped (see https://floating-ui.com/docs/misc#clipping)
The first one is quite simple to explain: popper.js is outdated and therefor it makes sense to upgrade to the successor project. Floating-UI fits so much better than popper.js for fritz2 and our headless-components, as it is in fact by itself only a headless component. It offers functionality to calculate the position of some floating element, but it does no more render some styling by itself to the DOM. This obviously is great news for our implementations of popup-elements, as the integration removes some pain points and works much smoother together with fritz2's core mechanics!
For the user there are not much changes; the core approach to implement PopUpPanel
as base for some floating brick, does not change. It is only about some properties that have different names and sometimes slightly different values. But we gain much more flexibility like the new concept of midlewares, that can be easily added by custom bricks. So in the end the user has more power to control and modify the floating element for his custom needs.
The other aspect of this PR adresses fundamental problems with overlayed elements in general (this includes the headless modals and toast on top of the PopupPanel
based components): There will always be a chance for clipping errors, if the overlay is rendered close to the trigger, which often is kinda deep down inside the DOM hierarchy. Please read about in the description of the Floating-UI project.
That said, nothing will change on API level for this aspect. It is a pure implementation aspect, that has changed now: All headless-overlay components will now render their overlay-bricks inside the original target-node of the outer render
-function, which acts as a portal-root. The trigger will create only some placeholder node into the DOM, whereas the real overlay content will be rendered inside the portal-root quite upside in the DOM. The placeholder acts as a portal to the final rendering place.
This is a common pattern and so we stick to the wording inside our code and name everything about it with portal
prefix.
API-Breaking Remarks and Migration-Guide
Be aware that this PR might be API breaking for the headless
package and the components, that implements the PopupPanel
-class for some of their bricks. Also your own Implementation that relies on PopupPanel
might break.
We do not consider this API breaking for the whole project, so we prefer to present this under the improvement aspect. We have no knowledge about users, using our headless-component yet, so obviously this will affect primarely ourselves.
There is one action needed to keep the headless components working: To use portalling you have to render the portalRoot
manually in our your render {} Block like this:
fun main() {
//...
render {
// custom content
// ...
portalRoot() // should be the last rendered element
}
}
After this patch, the Headless-Components listBox
, menu
, modal
, popOver
, toast
and tooltip
should be working again.
If you should against all expectations encounter a break, please simply refer to the updated list of properties within the headless documentation. This list and the following explanations should enable you to adapt to the new properties.
Further improvements
- PR #785: Add binary compatibility validation
Fixed Bugs
- PR #778: Fix link to Arrow docs
Credits
Special thanks to @serras for his effords and contributions to this release!
Version 1.0-RC7
Fixed Bugs
- PR #775: Fix a problem where a
PopUpPanel
's arrow would initially be visible regardless of the popup being hidden.
Version 0.14.6
Improvements
- PR #776: Update everything for latest Kotlin 1.9.0 version
Version 1.0-RC6
Breaking Changes
PR #772: Add Inspector.mapNull() method and fix Store.mapNull() path
This PR changes the behavior of Store.mapNull()
so that the path of the derived Store is the same as in the parent Store. This is the correct behavior, as a Store created via mapNull()
does not technically map to another hierarchical level of the data model. As a result, mapNull()
works the same on both Stores and Inspectors, making the validation process more straight-forward. Previously, the Store's id has been appended to the path upon derivation.
This might potentially be API breaking as the behavior of Store.mapNull()
regarding the resulting path changes.
Additionally, it adds an Inspector.mapNull()
extension function that works like Store.mapNull()
.
Just like on a Store, a default value is passed to the method that is used by the resulting store when the parent's value is null
.
Improvements
PR #765: Adds convenience execution functions for Unit metadata in validators
For convenience reasons it is now possible to call a validator with metadata type Unit
without the metadata parameter.
Imagine a Validator
of type Validator<SomeDomain, Unit, SomeMessage>
:
data class SomeDomain(...) {
companion object {
val validator: Validator<SomeDomain, Unit, SomeMessage> = validation { inspector -> ... }
// ^^^^
// no "real" metadata needed
}
}
val myData: SomeDomain = ...
// now invoke the execution process...
// old
SomeDomain.validator(myData, Unit) // need to pass `Unit`!
// new
SomeDomain.validator(myData) // can omit `Unit`
Fixed Bugs
Version 1.0-RC5
Breaking Changes
PR #763: Validation: Remove explicit nullability from metdata type
This PR changes the Validation
's metadata type to not be explicitly be nullable. Nullable types are still allowed, however.
The ValidatingStore
API has been changed accordingly.
Validation
Before
@JvmInline
value class Validation<D, T, M>(private inline val validate: (Inspector<D>, T?) -> List<M>) {
operator fun invoke(inspector: Inspector<D>, metadata: T? = null): List<M> = this.validate(inspector, metadata)
operator fun invoke(data: D, metadata: T? = null): List<M> = this.validate(inspectorOf(data), metadata)
}
Now
@JvmInline
value class Validation<D, T, M>(private inline val validate: (Inspector<D>, T) -> List<M>) {
// ^^^
// Metadata type is no longer explicitly nullable.
// Thus, it must always be specified.
//
operator fun invoke(inspector: Inspector<D>, metadata: T): List<M> = this.validate(inspector, metadata)
operator fun invoke(data: D, metadata: T): List<M> = this.validate(inspectorOf(data), metadata)
}
ValidatingStore
Before
open class ValidatingStore<D, T, M>(
initialData: D,
private val validation: Validation<D, T, M>,
val validateAfterUpdate: Boolean = true,
override val id: String = Id.next()
) : RootStore<D>(initialData, id) {
// ...
protected fun validate(data: D, metadata: T? = null): List<M> = /* ... */
}
Now
open class ValidatingStore<D, T, M>(
initialData: D,
private val validation: Validation<D, T, M>,
private val metadataDefault: T,
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// New parameter `metadataDefault`: Since the metadata must now be present at
// all times, a default value needs to bespecified for the automatic validation
// to work. During manual validation (via `validate(..)`), the metadata can still
// be passed in as before.
private val validateAfterUpdate: Boolean = true,
override val id: String = Id.next()
) : RootStore<D>(initialData, id) {
// ...
protected fun validate(data: D, metadata: T): List<M> = /* ... */
// ^^^^^^^^^^^
// Metadata now has to be specified at all times
}
Additionally, the convenience factory storeOf(...)
has been overloaded so ValidatingStore<D, Unit, M>
s can be created without the need to specify Unit
as the default metadata value manually.
Migration
Validation<D, T, M>.invoke(...)
now always requires metadata to be present. Add the missing metadata if necessary.- The
Validation<D, T, M>
constructor now requires the paramermetadataDefault
to be present. Add the missing metadats default if necessary.
PR #761: Inspector based Validation
Motivation
Real world domain objects are in most cases forms a deep object hierarchy by composing dedicated types in a sensefull way, for example some Person
consists of fields like name
or birthday
but also complex fields like some Address
, which itself represents some domain aspect with basic fields.
As validation is most of the time tied to its corresponding domain type, the validators mirror the same hierarchy as the domain classes. Thus they must be composable in the same way, the domain types are composed.
This was prior to this PR not supported by the 1.0-RC releases!
Solution
The API of the Validation
type changes just a little: The validate
lambda expression now provides no longer a (domain) type D
, but an Inspector<D>
! There are now two invoke
functions that take as first parameter an Inspector<D>
and as before just a D
, which then constructs the inspector itself. So one can use the latter for the (external) call of a validation with the domain object itself and the former for calling a validator from inside another validator!
Remark: If you have used the recommended validation
-factory functions, there will be no API breaking at all, as those already have used the Inspector<D>
. So it is very unlikely that this change will break existing code!
Example
The following composition now works:
// if you use the `headless` components, prefer to use the `ComponentValidationMessage` instead of handcrafting your own!
data class Message(override val path: String, val text: String) : ValidationMessage {
override val isError: Boolean = true
}
@Lenses
data class Person(
val name: String,
val birthday: LocalDate,
val address: Address // integrate complex sub-model, with its own business rules
) {
data class ValidationMetaData(val today: LocalDate, val knownCities: Set<String>)
companion object {
val validate: Validation<Person, ValidationMetaData, Message> = validation { inspector, meta ->
inspector.map(Person.name()).let { nameInspector ->
if (nameInspector.data.isBlank()) add(Message(nameInspector.path, "Name must not be blank!"))
}
inspector.map(Person.birthday()).let { birthdayInspector ->
if (birthdayInspector.data > meta.today)
add(Message(birthdayInspector.path, "Birthday must not be in the future!"))
}
// call validator of `Address`-sub-model and pass mapped inspector into it as data source and for
// creating correct paths!
// Voilà: Validator Composition achieved!
addAll(Address.validate(inspector.map(Person.address()), meta.knownCities))
}
}
}
@Lenses
data class Address(
val street: String,
val city: String
) {
companion object {
// enforce business rules for the `Address` domain
val validate: Validation<Address, Set<String>, Message> = validation { inspector, cities ->
inspector.map(Address.street()).let { streetInspector ->
if (streetInspector.data.isBlank()) add(Message(streetInspector.path, "Street must not be blank!"))
}
inspector.map(Address.city()).let { cityInspector ->
if (!cities.contains(cityInspector.data)) add(Message(cityInspector.path, "City does not exist!"))
}
}
}
}
// and then use those:
val fritz = Person(
"", // must not be empty!
LocalDate(1712, 1, 24),
Address("Am Schloss", "Potsdam") // city not in known cities list, see below
)
val errors = Person.validate(
fritz,
Person.ValidationMetaData(
LocalDate(1700, 1, 1), // set "today" into the past
setOf("Berlin", "Hamburg", "Braunschweig") // remember: no Potsdam inside
)
)
// three errors would appear:
// Message(".name", "Name must not be blank!")
// Message(".birthday", "Birthday must not be in the future!")
// Message(".address.city", "City does not exist!")
Migration Guide
If you have used the Validation.invoke
method directly, then prefer to switch to the dedicated validation
-factories!
Inside your validation code just remove the inspectorOf(data)
line, that you hopefully will find and change the name of the parameter to inspector
.
If you have not used any inspector based validation code, you simple must change the field access in such way:
// inside validation code:
// old
data.someField
// new
inspector.data.someField
Also try to replace handcrafted path
parameters of the Message
objects by relying on the inspector
object:
// old
add(SomeMessage(".someField", ...))
// new
add(SomeMessage(inspector.path, ...))
Improvements
- PR #762: Generate extension functions for Lens-Chaining to enable some fluent-style-API