Lazagna is a UI framework for developing asynchronous, event-driven browser user interfaces in the Scala language, using ScalaJS and ZIO.
The framework is called "Lazagna" because of the Z in ZIO, and of course because Lasagna, being made with layers, is a laminar.
For a nice example of a full application using Lazagna, see draw.
Lazagna does not use a virtual DOM. Instead, its HTML element DSL creates DOM elements directly, and relies on streams and events to perform differential updates directly. This is heavily inspired from the awesome laminar framework. However, where Laminar has its own streaming framework, Lazagna uses ZIO for its streams and concurrency.
The most basic building block in Lazagna is a Modifier
. This is defined as a trait with a single method:
package zio.lazagna.dom
trait Modifier {
def mount(parent: dom.Element): ZIO[Scope, Nothing, Unit]
}
A Modifier
has the following properties:
- It is mounted into the dom tree having a parent DOM tree
Element
. This need not imply that all modifiers create child elements; they might also be affecting the parent in other ways (or not at all). - It returns a ZIO that only has side effects when mounted (
Unit
) and can't fail (Nothing
). - The returned ZIO is allowed to use a
Scope
in its environment. That scope is typically tied to the lifetime of the parent, so that this modifier can clean up resources together with its parent going away.
In your application entry point, you should mount your main modifier to a DOM tree node. This involves just calling its mount
method directly. Typically, you only have one or few of these invocations.
import org.scalajs.dom
import zio.ZIOAppDefault
import zio.lazagna.dom.Modifier
object Main extends ZIOAppDefault {
val main: Modifier = ???
override def run = {
for {
_ <- main.mount(dom.document.querySelector("#app"))
_ <- ZIO.never // We don't want to exit main, since our background fibers do the real work.
} yield ExitCode.success
}
}
One special thing is that you don't want your main method to exit: that would remove its Scope
and stop all of your application.
The Element
class is a Modifier
which create elements, and allow arguments to create children. The Attribute
class is a Modifier
that sets attributes on its parent element. They combine as follows:
import zio.lazagna.dom.Element._
import zio.lazagna.dom.Attribute._
val tree = div(
div(
`class` := "dialog",
input(
`type` := "text"
)
)
)
See the respective classes for which elements and attributes are currently available.
In order to respond to events from any DOM EventTarget
, the EventsEmitter
class can be used. A ZIO will be invoked whenever an event occurs, in order to execute side effects. Events can also be directly sent to a Hub
or Ref
.
EventsEmitter
is defined as follows (simplified version shown here):
package zio.scala.dom
trait EventsEmitter[+T] {
def stream: ZStream[Scope with dom.EventTarget, Nothing, T]
def apply[U](op: ZIO[Scope, Filtered, T] => ZIO[Scope, Filtered, U]): EventsEmitter[U]
def -->(target: Hub[T]): Modifier
implicit def run: Modifier
}
An implicit conversion will turn the EventListener
into a Modifier
that registers and unregisters the event handler as needed, executing any transformations and side effects chained up using apply
.
import zio.lazagna.dom.Events._
val mouseHub: Hub[dom.MouseEvent]
input(
`type` := "test",
onClick(_.map(event => println(event))),
onMouseMove --> hub
)
Alternatively, the events can be viewed as a ZStream
by invoking e.g. onClick.stream.mapZIO(...)
. An implicit conversion will turn the stream into a Modifier
. However, the above push model is recommended as it doesn't require a background fiber for each running stream.
You may have noticed the Filtered
error type above. This is a convenience filter that allows a few stream-like operations (e.g. filter
or drain
) on a plain ZIO that is intended for side effects only. It allows a ZIO to decide not to handle an element by emitting Filtered
as an error (although obviously not a failure). A simplified version is defined like this:
package zio.lazagna
sealed trait Filtered {}
object Filtered {
implicit class zioOps[R,E,T](zio: ZIO[R,E,T]) {
/** Runs the underlying zio, but after that always fails with Filtered */
def drain: ZIO[R, E | Filtered, Nothing]
/** Filters the zio with the given predicate, failing with Filtered if it doesn't match */
def filter(p: T => Boolean): ZIO[R, E | Filtered, T]
/** Renamed from collect(), since .collect() is defined in ZIO has taking the error value as first argument. */
def collectF[U](pf: PartialFunction[T,U]): ZIO[R, E | Filtered, U]
}
}
An attribute can take the value from a Hub
, SubscriptionRef
, or directly from a ZStream
with the correct type, using the <--
method, and the Consumeable
type alias.
package zio.lazagna
type Consumeable[T] = ZStream[Scope & Setup, Nothing, T]
The Setup
type is similar to ZIO's built-in Scope
, but where Scope
allows one to register cleanup actions, Setup
registers startup actions (executed after the ZIO itself resolves its T
). These are typically background fibers that must be started before the rest of the flow continues (e.g. registering a subscription on a Hub
or SubscriptionRef
).
Implicit conversions exist from Hub
and SubscriptionRef
to Consumeable
. This way, you can set an attribute that automatically follows a value:
import zio.lazagna.Consumeable.given
case class Tool(name: String)
def currentTool: Consumeable[Tool]
div(
`class` <-- currentTool.map(t => s"main tool-${t.name}"),
)
Under the hood, a stream is created that updates the attribute on every value. The stream is forked into a background fiber that's tied to the Scope
of the modifier. That way, the stream automatically stops with that scope.
Various methods exist to dynamically vary the children of a parent element.
For instances where you want to replace all children whenever a value changes, use Alternative.mountOne
. It completely replaces the modifier whenever T
changes (by closing its scope).
package zio.lazagna.dom
object Alternative {
def mountOne[T](source: Consumeable[T])(render: T => Modifier): Modifier
}
If you want to keep several alternatives mounted in the DOM tree, but only show one, use Alternative.showOne
. This version will create all possible children up front, and use CSS to only show one at a time.
def showOne[T](source: Consumeable[T], alternatives: Map[T, Element[_]], initial: Option[T] = None): Modifier
If you want to add a child to an element explicitly at some point in time, use the Children
class. This allows you to designate a place where these children are rendered, and then later on "inject" children there. The injected children are still tied to a Scope
, so they will automatically disappear when that scope goes away.
package zio.lazagna.dom
trait Children {
/** Renders the children into their actual location. This must be invoked before .child() has any effect. */
def render: Modifier
def child[E <: dom.Element](creator: UIO[Unit] => Element[E]): ZIO[Scope, Nothing, Unit]
}
object Children {
def make: UIO[Children]
}
You use this as follows. First, make sure you create a Children
instance using Children.make
in central spot. Then, render that instance into your DOM tree:
div(
childrenInstance.render
)
Now, you can add children at will from any other place in your code where you have a scope available. For example, an event handler:
div(
cls := "child-client"
onClick(_.flatMap(_ => children.child { close =>
div(
cls := "dialog"
div(
cls := "button"
onClick(_.flatMap(_ => close))
)
)
}))
)
The child
function's creator
argument receives a UIO[Unit]
(called close
in our example), which can be invoked to destroy the created child. The child will also automatically be destroyed when its Scope
goes away (in our example, that's the div
with child-client
as CSS class).
As a final, lowest-level approach, you can manually manage the children of an Element
using the children <~~
operator:
val operations: Consumeable[Children.ChildOp]
div(
children <~~ operations
)
A selection of ChildOp
subclasses exist to add and remove children at specific spots, and/or rearrange them.
Various DOM abstractions are included, so applications can communicate in ZIO-style.
Making an AJAX XMLHttpRequest
request can be done through the zio.lazagna.dom.http.Request
abstraction. For example, sending a POST request and parsing the response as JSON:
for {
loginResp <- POST(AsDynamicJSON, s"${config.baseUrl}/users/${user}/login?password=${password}")
token = loginResp.token.asInstanceOf[String]
} yield ???
Various return types exist for Document
(XML), Blob
, ArrayBuffer
and String
(see AsXXXX
inside Request.scala
), and you can write your own by extending ResponseHandler[T]
.
You can make websocket requests through a ZIO abstraction using the zio.lazagna.dom.http.WebSocket
abstraction. The main entry point is defined as follows:
package zio.lazagna.dom.http.WebSocket
trait WebSocket {
def send(text: String): IO[WebSocketError, Unit]
def send(bytes: Array[Byte]): IO[WebSocketError, Unit]
}
object WebSocket {
def handle(url: String)(onMessage: dom.MessageEvent => ZIO[Any, Nothing, Any], onClose: => ZIO[Any, Nothing, Any] = ZIO.unit): ZIO[Scope, Nothing, WebSocket]
}
This allows both sending and receiving.
There are many ways to store data client-side, but the modern variant with least size restrictions is IndexedDB. Lazagna's abstraction on this is as follows.
package zio.lazagna.dom.indexeddb
trait Database {
def version: Version
def objectStoreNames: Seq[String]
def objectStore[T, TV <: js.Any, K](name: String)(using keyCodec: KeyCodec[K], valueCodec: ValueCodec[T,TV]): ObjectStore[T,K]
}
trait ObjectStore[T, K] {
def getRange(range: Range[K], direction: IDBCursorDirection = IDBCursorDirection.next): ZStream[Any, dom.ErrorEvent, T]
def getAll(direction: IDBCursorDirection = IDBCursorDirection.next): ZStream[Any, dom.ErrorEvent, T]
def add(value: T, key: K): Request[Unit]
def clear: Request[Unit]
def delete(key: K): Request[Unit]
}
object IndexedDB {
def open(name: String, schema: Schema): ZIO[Scope, Blocked | dom.ErrorEvent, Database]
}
For an example on how to interact with indexed DB, see IndexedDBEventStore
.
Since an IndexedDB instance is shared between browser tabs, it can be necessary to collaborate between tabs to decide who can write to the database. The Web Locks API can help with this, for which Lazagna provides an abstraction.
package zio.lazagna.dom.weblocks
trait Lock {
def withExclusiveLock[R,E,A](zio: =>ZIO[R,E,A]): ZIO[R,E,A]
def withExclusiveLockIfAvailable[R,E,A](zio: =>ZIO[R,E,A]): ZIO[R, E | LockUnavailable.type, A]
}
object Lock {
def make(name: String): UIO[Lock]
}
An interactive whiteboarding application uses Lazagna. Read more about it here.
- Clean up the use of implicit and given, and align on having a nice one-line import for library users
- Write unit tests to anchor functionality (once ZIO abstractions are confirmed, which they are not)
- The initial
EventsEmitter
class just created aZStream
for the events. This required aFiber
for every event handler. They turned out to be fast to create, but relatively slow to stop (about 1 second to stop 1000 fibers, on a fast desktop machine). If you have 1000 small icons to select from, that's too slow. Hence, a push-based model was introduced. - ZIO could perhaps do with a
Filtered
class of its own, instead of or in addition to the genericfilter
error variants. - ZStream could perhaps add a push-based stream variant, which maintains stream operation in a scope, executing a ZIO for every element. We'd have to define more precise semantics though (return type of the ZIO would have to be
Chunk[U]
, and we need a way to early close the stream to yield a value, sinceEventEmitter
doesn't need that). - Should
Setup
be part ofScope
? Or is it not necessary here at all?