Sscalascript.dev

Frontend SPI — usage guide

How to write a reactive SPA in ScalaScript and ship it to any of the four supported frontend backends. Companion docs:

primitive contract every backend honours (the what).

— SPI mechanics and per-framework trade-offs table (the why and how-it-binds).

reference apps that exercise everything below.

This doc is the user-facing surface of the SPI as of v1.18 Phase A8: which primitives exist, how to select a backend, and what each one lowers to.

TL;DR

// 1. Pick a backend (build-time choice).
setFrontendFramework("solid")   // or "custom" | "react" | "vue"

// 2. Create reactive state.
val count = new ReactiveSignal[Int]("count", 0)

// 3. Describe the view tree.
val app = ComponentDef("App", Nil, _ => View.Element(
  "div", Map.empty, Map.empty, Seq(
    View.Element("button",
      Map("id" -> AttrValue.Str("inc")),
      Map("click" -> EventHandler.IncrementSignal(count)),
      Seq(View.TextNode(() => "+"))
    ),
    View.Element("span", Map.empty, Map.empty, Seq(View.SignalText(count)))
  )
))

// 4. Lower to JS.
val emitted = FrontendFrameworks.current()
  .emit(FrontendModule(List(app), "App", "/"))
// emitted.js + emitted.html + emitted.css are ready to serve.

The same app definition compiles to four different JS bundles depending on setFrontendFramework. See the reference apps for fuller worked examples.

Choosing a backend

The trade-offs table in frontend-framework-spi-plan.md is the canonical reference. Quick mental model:

NeedRecommended backend
Zero JS deps, smallest bundlecustom
Existing React design system / component libreact
Best perf on granular state changessolid
Familiar template syntax + smooth proxy reactivityvue

All four implement the same set of primitives — switching is a build- time flag, not a rewrite.

Selecting at build time

From .ssc source

The setFrontendFramework intrinsic (v1.18 Phase A7) flips the FrontendFrameworks.setBackend(name) choice for the rest of the program:

setFrontendFramework("react")

// downstream emit code now routes through ReactFrameworkBackend

Unknown names raise IllegalStateException with the list of impls that are on the classpath — loud failure over silent fallback.

From the CLI

ssc emit-spa --frontend react app.ssc > spa.html
ssc emit-spa --frontend solid app.ssc > spa.html

The CLI bundles all four frontend-{custom,react,solid,vue} modules so every name resolves out of the box. Validation lives in validFrontendNames; unknown names exit non-zero with an error.

From Scala-host integration code

If you're driving the SPI directly from JVM-side glue (e.g., a build script that wants to emit all four backends — see EmitAll.scala), construct the backend impl directly or call FrontendFrameworks:

import scalascript.frontend.*
import scalascript.frontend.react.ReactFrameworkBackend

val backend: FrontendFrameworkSpi = new ReactFrameworkBackend
val emitted: EmittedSpa = backend.emit(myModule)

// or, with ServiceLoader discovery + the global selection:
FrontendFrameworks.setBackend("solid")
val emitted2 = FrontendFrameworks.current().emit(myModule)

Reactive primitives

ReactiveSignal[T]

A reactive cell with a JS-safe jsName (used by the emitter as the identifier for the cell across the bundle) and a primitive initial value (String | Int | Long | Double | Boolean).

val count    = new ReactiveSignal[Int]("count", 0)
val username = new ReactiveSignal[String]("username", "anonymous")
val online   = new ReactiveSignal[Boolean]("online", true)

What this lowers to:

BackendLowering
custom__ssc_signals['count'] cell + Set of subscribers
reactconst [count, setCount] = useState(0) hoisted to component
solidconst [count, setCount] = createSignal(0)
vueconst count = ref(0) returned from setup()

ReactiveSignalList[T]

A reactive sequence of T values; same naming + primitive-type restriction as ReactiveSignal.

val todos = new ReactiveSignalList[String]("todos", Seq("first"))

Lowering: identical strategy to ReactiveSignal but stores an array in the cell. Backends subscribe to list changes for View.ForSignal rendering.

View.SignalText

A reactive text node bound to a ReactiveSignal[?]. The emitter generates a subscription that updates textContent (Custom / Solid) or interpolates the current value into the re-rendered tree (React / Vue).

View.SignalText(count)         // shows "0", "1", "2", … as count changes

View.ShowSignal

A reactive conditional sub-tree. cond is a ReactiveSignal[Boolean]; the subtree swaps reactively when the signal flips.

View.ShowSignal(
  cond      = visible,
  whenTrue  = View.Element("span", ..., Seq(View.SignalText(count))),
  whenFalse = View.TextNode(() => "")
)
BackendLowering
customSubscription on the visible cell that swaps a placeholder node
reactTernary inside render()useState change triggers re-render
solidcreateEffect that wipes/rebuilds the conditional region
vueTernary inside the render arrow — proxy change triggers re-render

(For static "evaluated once at emit time" conditionals use the plain View.Show — its cond is a () => Boolean JVM closure and is snapshot at emit, not subscribed.)

View.ForSignal

Repeated sub-trees backed by a ReactiveSignalList[T]. Each item renders as <tag attrs>String(item)</tag> — single-tag-per-item is the current scope.

View.Element("ul", Map.empty, Map.empty, Seq(
  View.ForSignal(items = todos, tag = "li", attrs = Map.empty)
))

Rich per-item templates (nested elements, per-item events) need a richer IR; they're deferred to a follow-up phase.

Event handlers

The IR has two "closure-shaped" handlers (EventHandler.Simple(() => Unit) and EventHandler.WithEvent(Any => Unit)) and four "translatable" handlers that the emitter can lower into real JS without translating an arbitrary JVM closure.

The translatable handlers are the recommended path. The closure-shaped ones are kept in the IR for completeness (a future phase may lower limited closures); today every backend emits a marker comment in their place rather than a working handler — explicitly documented in each emitter so this isn't a silent footgun.

HandlerWhat it does
EventHandler.IncrementSignal(s, by)Add by (default 1) to a ReactiveSignal[Int]
EventHandler.SetSignalLiteral(s, v)Set a ReactiveSignal[?] to a JS-literal value
EventHandler.ToggleSignal(s)Flip a ReactiveSignal[Boolean]
EventHandler.PushSignalLiteral(l, v)Append a literal to a ReactiveSignalList[T]
EventHandler.ClearSignalList(l)Reset a ReactiveSignalList[T] to empty

Examples:

// + button
View.Element("button",
  Map.empty,
  Map("click" -> EventHandler.IncrementSignal(count, by = 1)),
  Seq(View.TextNode(() => "+"))
)

// reset button
View.Element("button",
  Map.empty,
  Map("click" -> EventHandler.SetSignalLiteral(count, 0)),
  Seq(View.TextNode(() => "reset"))
)

// add-todo button
View.Element("button",
  Map.empty,
  Map("click" -> EventHandler.PushSignalLiteral(todos, "new item")),
  Seq(View.TextNode(() => "add"))
)

Lowering shape (illustrative, React):

const onClickInc   = () => setCount(c => c + 1);
const onClickReset = () => setCount(0);
const onClickAdd   = () => setTodos(arr => [...arr, "new item"]);

Four reference apps

Four small demos live under examples/frontend/ and are built from frontend-examples/src/main/scala/scalascript/frontend/examples/:

  1. counterReactiveSignal[Int] + IncrementSignal +

SetSignalLiteral + SignalText.

  1. show-hideReactiveSignal[Boolean] + ToggleSignal +

ShowSignal.

  1. todoReactiveSignalList[String] + PushSignalLiteral +

ClearSignalList + ForSignal.

  1. toolkit-demo — Frontend Toolkit through the Tk facade —

Stack, Heading, Card, TextField, Checkbox, Button, Badge, Alert, Spinner, theme tokens. Proves the high-level toolkit lowers through every backend's emit pipeline.

Compile, generate, run:

# 1. Compile the demo sources + run the cross-backend test suite
sbt frontendExamples/compile
sbt frontendExamples/test                    # 41 tests across 2 suites

# 2. Generate static bundles — 16 (4 demos x 4 backends) HTML+JS pairs
sbt "frontendExamples/runMain scalascript.frontend.examples.EmitAll"
# → target/frontend-examples/<demo>/<backend>/{index.html,app.js}
#
# Optional explicit out-dir:
sbt "frontendExamples/runMain scalascript.frontend.examples.EmitAll /tmp/ssc-spa"

# 3. Serve in a browser via the bundled ssc static server
#    (Vue/Solid/Custom emit ES modules — file:// won't work for them.)
ssc serve 8000 target/frontend-examples/toolkit-demo/react
# Then open http://localhost:8000/
#
# Same pattern for the other demos / backends:
ssc serve 8000 target/frontend-examples/toolkit-demo/vue
ssc serve 8000 target/frontend-examples/counter/solid
ssc serve 8000 target/frontend-examples/todo/custom

ssc serve [port] [dir] is the built-in static-file server in the CLI — no Python or Node needed. Defaults: port 8080, dir .. It prints both the local URL and any detected LAN URLs for opening the same page from another device on the network.

The per-demo READMEs explain each demo's .ssc-level intent and how the four backends emit it.

SSR — render the toolkit tree to plain HTML

For SEO, static-site generation, email templates, or snapshot tests the toolkit ships a pure View → HTML stringifier (no DOM, no signal subscriptions):

import scalascript.frontend.toolkit.{Tk, Ssr, Theme}

val tree = Tk.vstack(gap = 16)(
  Tk.heading(1, "Static page"),
  Tk.text("Rendered without any JS runtime.")
)

val html = Ssr.renderToHtml(tree)                  // just the body
val doc  = Ssr.renderDocument(tree, title = "Demo")  // full HTML5 shell

Limitations (as of v1.18 A8)

ReactiveSignalList[T] restrict T to a JSON-literal type (String | Int | Long | Double | Boolean) so the initial value can be embedded into JS without a portable encoder. Widening waits on a serialiser story.

per-item views need either compile-time inlining of a T => View template or a runtime view DSL on the JS side — both deferred.

EventHandler.WithEvent emit a marker comment, not a real handler. Use IncrementSignal / SetSignalLiteral / ToggleSignal / PushSignalLiteral / ClearSignalList for working clicks.

(in progress). Capability.DomRefs, Capability.Context, Capability.Suspense, Capability.Portals declarations on individual backends only describe planned coverage; the corresponding View / EventHandler constructors don't exist yet.

the HTML shell but no View.Route(...) primitive exists yet.

separate Fr8 follow-up (see the SPI plan doc).

uses CDN script tags or import maps so demos run without a bundler; Solid's output expects import 'solid-js' to resolve through your own toolchain (Vite / esbuild / etc.).

identifies a single top-level component; sub-components are inlined at emit time rather than emitted as separate framework- level components. A future phase will lower nested Component[P] into framework-native function components.