Grove.LiveView (Grove v0.1.1)

View Source

LiveView integration for Grove CRDT trees.

This module provides helper functions for using Grove trees in Phoenix LiveView applications with real-time synchronization across connected clients.

Usage

defmodule MyAppWeb.FormLive do
  use MyAppWeb, :live_view

  def mount(%{"id" => doc_id}, _session, socket) do
    socket = Grove.LiveView.mount_tree(socket, doc_id,
      replica_id: socket.id,
      pubsub: MyApp.PubSub
    )
    {:ok, socket}
  end

  def handle_info({:grove_delta, delta, from_pid}, socket) when from_pid != self() do
    socket = Grove.LiveView.apply_remote(socket, delta)
    {:noreply, socket}
  end

  def handle_event("update_field", %{"node_id" => node_id, "value" => value}, socket) do
    socket = Grove.LiveView.apply_and_broadcast(socket, fn tree ->
      updated_tree = Grove.Tree.update_node(tree, node_id, fn node ->
        Grove.Node.update_attrs(node, %{value: value})
      end)
      delta = {:update_attrs, node_id, %{value: value}}
      {updated_tree, delta}
    end)
    {:noreply, socket}
  end
end

Socket Assigns

This module stores the following assigns in the socket (all prefixed with grove_):

  • :grove_tree - The current Grove.Tree struct
  • :grove_replica_id - Unique identifier for this replica
  • :grove_doc_id - Document identifier for the tree
  • :grove_topic - PubSub topic for synchronization
  • :grove_pubsub - PubSub module being used
  • :grove_update_buffer - List of pending deltas for debounced broadcast
  • :grove_buffer_timer - Timer reference for debounce flush

Configuration

You can configure a default PubSub module in your config:

config :grove, pubsub: MyApp.PubSub

Or pass it explicitly to mount_tree/3.

Summary

Functions

Applies a local operation and broadcasts the delta to peers.

Applies a remote delta received from PubSub.

Buffers an update for debounced broadcast.

Clears the cursor position for this replica.

Clears the selection for this replica.

Flushes the update buffer, broadcasting all pending deltas.

Gets the document ID for this socket.

Gets the current replica's presence metadata.

Gets all presences for the current document.

Gets the replica ID for this socket.

Gets the current tree from the socket.

Mounts a Grove tree into the socket and subscribes to updates.

Updates the cursor position for this replica.

Updates which node has focus for this replica.

Updates the selection range for this replica.

Updates the tree in the socket without broadcasting.

Types

delta()

@type delta() :: term()

socket()

@type socket() :: Phoenix.LiveView.Socket.t() | map()

Functions

apply_and_broadcast(socket, fun)

@spec apply_and_broadcast(socket(), (Grove.Tree.t() -> {Grove.Tree.t(), delta()})) ::
  socket()

Applies a local operation and broadcasts the delta to peers.

The function receives the current tree and should return a tuple of {updated_tree, delta} where delta is the change to broadcast.

If there are pending buffered updates, they are flushed first to maintain operation order.

Example

socket = Grove.LiveView.apply_and_broadcast(socket, fn tree ->
  updated_tree = Grove.Tree.update_node(tree, node_id, &update_fn/1)
  delta = {:update, node_id, changes}
  {updated_tree, delta}
end)

apply_remote(socket, delta, apply_fn \\ &default_apply/2)

@spec apply_remote(socket(), delta(), (Grove.Tree.t(), delta() -> Grove.Tree.t())) ::
  socket()

Applies a remote delta received from PubSub.

This function should be called from your handle_info/2 callback when receiving :grove_delta messages.

The apply_fn receives the current tree and the delta, and should return the updated tree.

Batch deltas ({:batch, [deltas]}) are automatically handled by applying each delta sequentially in order.

Example

def handle_info({:grove_delta, delta, from_pid}, socket) when from_pid != self() do
  socket = Grove.LiveView.apply_remote(socket, delta, fn tree, delta ->
    # Apply the delta to your tree
    apply_delta(tree, delta)
  end)
  {:noreply, socket}
end

If no apply function is provided, the delta is expected to be a tree and will replace the current tree directly.

buffer_update(socket, fun, opts \\ [])

@spec buffer_update(
  socket(),
  (Grove.Tree.t() -> {Grove.Tree.t(), delta()}),
  keyword()
) :: socket()

Buffers an update for debounced broadcast.

The update is applied to the tree immediately for local responsiveness, but the delta is buffered and only broadcast after the debounce period.

This is useful for high-frequency operations like typing or cursor moves where you want immediate local feedback but reduced network traffic.

Options

  • :debounce - Debounce period in milliseconds. Defaults to 100ms.

Example

# High-frequency typing events
socket = Grove.LiveView.buffer_update(socket, fn tree ->
  updated_tree = Grove.Tree.update_node(tree, node_id, fn node ->
    Grove.Node.update_attrs(node, %{value: value})
  end)
  delta = {:update_value, node_id, value}
  {updated_tree, delta}
end, debounce: 150)

Timer Handling

You must handle the :grove_flush_buffer message in your LiveView:

def handle_info(:grove_flush_buffer, socket) do
  {:noreply, Grove.flush_buffer(socket)}
end

clear_cursor(socket)

@spec clear_cursor(socket()) :: socket()

Clears the cursor position for this replica.

Example

socket = Grove.LiveView.clear_cursor(socket)

clear_selection(socket)

@spec clear_selection(socket()) :: socket()

Clears the selection for this replica.

Example

socket = Grove.LiveView.clear_selection(socket)

flush_buffer(socket)

@spec flush_buffer(socket()) :: socket()

Flushes the update buffer, broadcasting all pending deltas.

If the buffer is empty, this is a no-op.

Pending deltas are wrapped in {:batch, [deltas]} and broadcast to all subscribers. The batch preserves the order of operations.

Example

def handle_info(:grove_flush_buffer, socket) do
  {:noreply, Grove.flush_buffer(socket)}
end

get_doc_id(socket)

@spec get_doc_id(socket()) :: String.t()

Gets the document ID for this socket.

get_my_presence(socket)

@spec get_my_presence(socket()) :: map() | nil

Gets the current replica's presence metadata.

Returns the metadata map or nil if not tracking presence.

Example

case Grove.LiveView.get_my_presence(socket) do
  %{cursor: cursor} -> # do something with cursor
  nil -> # not tracking presence
end

get_presences(socket)

@spec get_presences(socket()) :: map()

Gets all presences for the current document.

Returns a map of %{replica_id => %{metas: [meta]}}.

Example

presences = Grove.LiveView.get_presences(socket)
# %{
#   "replica_1" => %{metas: [%{cursor: %{node_id: "f1", offset: 10}, ...}]},
#   "replica_2" => %{metas: [%{cursor: nil, ...}]}
# }

get_replica_id(socket)

@spec get_replica_id(socket()) :: String.t()

Gets the replica ID for this socket.

get_tree(socket)

@spec get_tree(socket()) :: Grove.Tree.t()

Gets the current tree from the socket.

Example

tree = Grove.LiveView.get_tree(socket)
nodes = Grove.Tree.find_nodes(tree, type: :text)

mount_tree(socket, doc_id, opts)

@spec mount_tree(socket(), String.t(), keyword()) :: socket()

Mounts a Grove tree into the socket and subscribes to updates.

This function initializes the socket with a Grove tree and subscribes to the PubSub topic for real-time synchronization.

Options

  • :replica_id - Required. Unique identifier for this replica (e.g., socket.id)
  • :tree - Optional. Initial tree. Defaults to an empty tree.
  • :pubsub - Optional. PubSub module. Defaults to configured value.

Example

socket = Grove.LiveView.mount_tree(socket, "doc_123",
  replica_id: socket.id,
  pubsub: MyApp.PubSub
)

set_cursor(socket, node_id, offset \\ nil)

@spec set_cursor(socket(), String.t(), non_neg_integer() | nil) :: socket()

Updates the cursor position for this replica.

The cursor represents the caret position within a node.

Parameters

  • socket - LiveView socket with presence tracking enabled
  • node_id - The node ID where the cursor is positioned
  • offset - Optional character offset within the node

Example

socket = Grove.LiveView.set_cursor(socket, "field_123", 42)

set_focus(socket, node_id)

@spec set_focus(socket(), String.t() | nil) :: socket()

Updates which node has focus for this replica.

Focus indicates which field or node the user is currently interacting with.

Example

socket = Grove.LiveView.set_focus(socket, "field_123")

set_selection(socket, start_node, end_node, opts \\ [])

@spec set_selection(socket(), String.t(), String.t(), keyword()) :: socket()

Updates the selection range for this replica.

A selection spans from a start position to an end position, potentially across multiple nodes.

Parameters

  • socket - LiveView socket with presence tracking enabled
  • start_node - Node ID where selection starts
  • end_node - Node ID where selection ends
  • start_offset - Optional character offset at start
  • end_offset - Optional character offset at end

Example

socket = Grove.LiveView.set_selection(socket,
  "field_1", "field_2",
  start_offset: 10,
  end_offset: 25
)

update_tree(socket, fun)

@spec update_tree(socket(), (Grove.Tree.t() -> Grove.Tree.t())) :: socket()

Updates the tree in the socket without broadcasting.

Use this for local-only updates that don't need to sync.