Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Browser integration

Run a composed client-only command, then ask a focused JavaScript hook to copy a sample string. Clipboard access depends on browser permissions and a secure context, so denial is handled as an ordinary typed result rather than a crash.

LiveView
scala
final class BrowserInteropExample(instanceId: String)
    extends LiveView[BrowserInteropExample.Msg, BrowserInteropExample.Model]:
  import BrowserInteropExample.*

  private val hookRef        = DomRef(s"$instanceId-hook")
  private val panelRef       = DomRef(s"$instanceId-panel")
  private val placeholderRef = DomRef(s"$instanceId-placeholder")
  private val detailRef      = DomRef(s"$instanceId-detail")

  private val clientOnlyCommand =
    JS.show(to = panelRef.selector)
      .hide(to = placeholderRef.selector)
      .toggle(to = detailRef.selector)

  private val resetCommand =
    JS.push(Msg.Reset)
      .hide(to = panelRef.selector)
      .hide(to = detailRef.selector)
      .show(to = placeholderRef.selector)

  override def hooks: LiveHooks[Msg, Model] =
    LiveHooks.empty[Msg, Model].onBrowserEvent(CopyResultEvent) { (model, result, _) =>
      ZIO.succeed(applyCopyResult(model, result))
    }

  def mount(ctx: MountContext): Task[Model] = ZIO.succeed(Model())

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.CopySample =>
      val requestNumber = model.requestNumber + 1
      val requestId     = s"copy-$requestNumber"
      ctx.client
        .push(CopyRequestEvent, CopyRequest(requestId, SampleText))
        .as(model.copy(requestNumber = requestNumber, operation = CopyOperation.Pending(requestId)))
    case Msg.Reset => ZIO.succeed(Model())

  def view(model: Signal[Model]): HtmlElement[Msg] =
    div(
      dom.hook(HookName, hookRef),
      cls := "docs-browser-integration",
      sectionTag(
        dataAttr("example-controls") := "",
        h3("Compose client-only commands"),
        p(
          "This click shows a panel, hides the placeholder, and toggles a detail without sending a server message."
        ),
        button(typ := "button", on.click(clientOnlyCommand), "Run composed command"),
        p(placeholderRef.attr, "The command will hide this placeholder."),
        div(
          panelRef.attr,
          styleAttr := "display: none;",
          strong("The command showed this panel."),
          p(
            detailRef.attr,
            styleAttr := "display: none;",
            "Run it again to toggle this detail."
          )
        )
      ),
      sectionTag(
        dataAttr("example-controls") := "",
        h3("Exchange typed browser events"),
        p("Scala asks the hook to copy this sample and accepts only the correlated result."),
        codeTag(SampleText),
        div(
          cls := "docs-browser-actions",
          button(
            typ := "button",
            on.click(Msg.CopySample),
            model.map(model =>
              if model.operation.isPending then "Retry copy" else "Copy sample text"
            )
          ),
          button(typ := "button", on.click(resetCommand), "Reset browser integration")
        ),
        p(
          dataAttr("browser-copy-status") := "",
          role                            := "status",
          model.map(_.operation.label)
        )
      )
    )
end BrowserInteropExample

object BrowserInteropExample:
  final case class CopyRequest(requestId: String, text: String) derives JsonEncoder
  final case class CopyResult(requestId: String, ok: Boolean) derives JsonDecoder

  final case class Model(
    requestNumber: Long = 0,
    operation: CopyOperation = CopyOperation.Idle)

  enum Msg:
    case CopySample
    case Reset

  enum CopyOperation(val label: String, val traceLabel: String):
    case Idle extends CopyOperation("No browser operation requested yet.", "idle")
    case Pending(requestId: String)
        extends CopyOperation("Waiting for the browser result. Retry if needed.", "pending")
    case Succeeded extends CopyOperation("Browser operation completed.", "succeeded")
    case Failed    extends CopyOperation("Browser operation could not be completed.", "failed")

    def isPending: Boolean = this match
      case Pending(_) => true
      case _          => false

  val SampleText = "Scalive keeps server-to-browser event payloads typed."

  private val HookName         = "BrowserInterop"
  private val CopyResultEvent  = BrowserToServerEvent[CopyResult]("browser-copy-result")
  private val CopyRequestEvent = ServerToBrowserEvent[CopyRequest]("browser-copy-request")
  private val codeTag          = HtmlTag("code")

  def applyCopyResult(model: Model, result: CopyResult): Model =
    val nextOperation = model.operation match
      case CopyOperation.Pending(requestId) if requestId == result.requestId =>
        if result.ok then CopyOperation.Succeeded else CopyOperation.Failed
      case current => current
    model.copy(operation = nextOperation)
end BrowserInteropExample
Browser hook
javascript
const maxRequestIdLength = 64
const maxTextLength = 4096

export function readCopyRequest(payload) {
  const requestId = typeof payload?.requestId === "string" ? payload.requestId : ""
  const text = typeof payload?.text === "string" ? payload.text : undefined
  if (
    requestId.length === 0 ||
    requestId.length > maxRequestIdLength ||
    text === undefined ||
    text.length > maxTextLength
  ) return undefined
  return { requestId, text }
}

export function createBrowserInteropHook(clipboard = globalThis.navigator?.clipboard) {
  return {
    mounted() {
      this.isDestroyed = false
      this.handleEvent("browser-copy-request", async (payload) => {
        if (this.isDestroyed) return

        const request = readCopyRequest(payload)
        let ok = false
        if (request && clipboard?.writeText) {
          try {
            await clipboard.writeText(request.text)
            ok = true
          } catch {
            ok = false
          }
        }

        if (this.isDestroyed) return
        try {
          await this.pushEvent("browser-copy-result", {
            requestId: request?.requestId ?? "",
            ok,
          })
        } catch {
          // The LiveSocket may disconnect while browser work is completing.
        }
      })
    },

    destroyed() {
      this.isDestroyed = true
    },
  }
}

Live result

ConnectedReconnectingRead-only

Compose client-only commands

This click shows a panel, hides the placeholder, and toggles a detail without sending a server message.

The command will hide this placeholder.

Exchange typed browser events

Scala asks the hook to copy this sample and accepts only the correlated result.

Scalive keeps server-to-browser event payloads typed.

No browser operation requested yet.

Disconnected. Controls resume after reconnection.

LiveView observability

Interaction inspector

See how actions in the live result above travel through typed messages, server state, protocol frames, and DOM updates.

Reconnect to inspect live interactions

The live result and its inspector resume when the connection returns.

Related guidance: integrate browser behavior with commands, directional events, stable hook IDs, correlation, and cleanup.