Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline

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())

  override 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
    },
  }
}

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.

UnavailableConnect to capture interactions

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