Search documentation of phoenix_kit

# Making Pages Live: Real-time Updates & Collaborative Editing

View Source

This guide explains how to add real-time functionality to LiveView pages in PhoenixKit, including PubSub events and Phoenix.Presence-based collaborative editing.

## Table of Contents

1. Overview
2. WebSocket Requirement
3. Quick Start
4. Events System
5. Collaborative Editing with Phoenix.Presence
6. On Mount Hooks
7. Common Patterns
8. Troubleshooting

## Overview

PhoenixKit provides two main systems for real-time functionality:

### 1. Events System (PubSub Broadcasting)

Location: `lib/phoenix_kit/entities/events.ex`

Broadcasts create/update/delete events across all LiveView sessions.

Key Functions:

- `subscribe_to_entities()` - Subscribe to all entity events
- `subscribe_to_entity_data(entity_id)` - Subscribe to specific entity's data events
- `broadcast_entity_created(entity_id)` - Notify about new entity
- `broadcast_entity_updated(entity_id)` - Notify about entity changes
- `broadcast_data_updated(entity_id, data_id)` - Notify about data changes

### 2. Phoenix.Presence (Collaborative Editing)

Location: `lib/phoenix_kit/entities/presence.ex` + `lib/phoenix_kit/entities/presence_helpers.ex`

Distributed presence tracking with automatic role assignment for collaborative editing.

How It Works:

- Multiple users can open the same edit form simultaneously
- First user in the list = owner (can edit)
- Everyone else = spectators (read-only, see real-time updates)
- When owner leaves, next person auto-promoted (within 5 seconds)
- No manual locks or cleanup needed

Key Functions:

- `PresenceHelpers.track_editing_session(type, id, socket, user)` - Join editing session
- `PresenceHelpers.get_editing_role(type, id, socket_id, user_id)` - Determine if owner or spectator
- `PresenceHelpers.get_sorted_presences(type, id)` - Get all editors (FIFO ordered)
- `PresenceHelpers.subscribe_to_editing(type, id)` - Subscribe to presence changes

### 3. SimplePresence (Admin Session Tracking)

Location: `lib/phoenix_kit/admin/simple_presence.ex`

Lightweight session tracking for admin dashboard statistics only.

Key Functions:

- `track_anonymous(session_id, metadata)` - Track anonymous visitors
- `track_user(user, metadata)` - Track authenticated users
- `get_presence_stats()` - Get dashboard statistics

## WebSocket Requirement

### Critical: WebSocket-Only Mode for Instant Presence Cleanup

Phoenix LiveView supports both WebSocket and long-polling transports. PhoenixKit requires WebSocket-only mode to enable instant presence cleanup for collaborative editing.

#### Problem with Long-Polling Fallback

If long-polling fallback is enabled, LiveView processes can linger for up to 30 seconds after a tab is closed or refreshed, causing:

- ❌ Stale "ghost" users in presence lists for ~30 seconds
- ❌ Slow role transitions in collaborative editing (owner → spectator)
- ❌ Poor user experience with delayed updates
- ❌ Presence entries not removed immediately on tab close/refresh

#### Solution (Already Applied)

PhoenixKit has already disabled long-polling in its endpoint configuration:

    # lib/phoenix_kit_web/endpoint.ex (line 14)
    socket "/live", Phoenix.LiveView.Socket,
      websocket: [connect_info: [session: @session_options]],
      longpoll: false  # ← Forces WebSocket-only connections

No parent application changes are required. The server-side configuration is sufficient.

#### Benefits of WebSocket-Only Mode

With WebSocket-only mode enabled:

- ✅ Presence entries removed instantly when tabs close
- ✅ Presence entries removed instantly when pages refresh
- ✅ Collaborative editing roles (owner/spectator) update immediately
- ✅ No 30-second delay for presence cleanup
- ✅ Clean, responsive real-time collaboration

#### Production Considerations

When deploying PhoenixKit with WebSocket-only mode, ensure your infrastructure supports WebSocket connections:

1. Reverse Proxies (NGINX, Apache, etc.)

Must forward WebSocket upgrade headers. Example NGINX config:

    location /live {
      proxy_pass http://backend;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
    }

2. CDN/Load Balancers (Cloudflare, AWS ALB, etc.)

- Must support WebSocket connections
- May require specific routing rules for `/live/websocket` path

3. Firewall/Security Rules

- Must allow WebSocket traffic on your application port
- Must not block HTTP upgrade requests

#### Testing WebSocket Connections

To verify WebSocket-only mode is working:

1. Open a collaborative editing page (entity or data form)
2. Open browser Developer Tools → Network tab
3. Look for WebSocket connection: `ws://localhost:4000/live/websocket`
4. Refresh the page
5. Verify the old WebSocket closes immediately
6. Verify presence list updates within 1 second

If you see `Transport: :longpoll` in server logs, WebSocket connections are failing and clients are attempting fallback (which should not happen with `longpoll: false`).

#### Fallback Strategy (Not Recommended)

If WebSocket support is absolutely unavailable in your environment:

1. Remove `longpoll: false` from endpoint configuration to re-enable fallback
2. Accept the 30-second delay for presence cleanup
3. Consider adding UI indicators to show stale connections
4. Implement periodic refresh to clean up dead PIDs faster

Note: This significantly degrades the collaborative editing experience and is not recommended for production use.

## Quick Start

### Example 1: Add Real-time Updates to a List Page

    defmodule PhoenixKitWeb.Live.MyResourcesLive do
      use PhoenixKitWeb, :live_view

      alias PhoenixKit.Entities.Events
      alias PhoenixKit.MyContext

      @impl true
      def mount(_params, _session, socket) do
        # Subscribe to events when connected
        if connected?(socket) do
          Events.subscribe_to_entities()
        end

        resources = MyContext.list_resources()

        {:ok, assign(socket, :resources, resources)}
      end

      # Handle real-time updates
      @impl true
      def handle_info({:entity_created, _entity_id}, socket) do
        # Reload and update the list
        resources = MyContext.list_resources()
        {:noreply, assign(socket, :resources, resources)}
      end

      @impl true
      def handle_info({:entity_updated, _entity_id}, socket) do
        resources = MyContext.list_resources()
        {:noreply, assign(socket, :resources, resources)}
      end

      @impl true
      def handle_info({:entity_deleted, _entity_id}, socket) do
        resources = MyContext.list_resources()
        {:noreply, assign(socket, :resources, resources)}
      end
    end

### Example 2: Add Collaborative Editing to a Form

    defmodule PhoenixKitWeb.Live.MyResourceFormLive do
      use PhoenixKitWeb, :live_view

      alias PhoenixKit.Entities.{Events, PresenceHelpers}

      @impl true
      def mount(%{"id" => id}, _session, socket) do
        resource = MyContext.get_resource!(id)
        form_key = "resource_#{id}"

        socket = assign(socket,
          resource: resource,
          form_key: form_key,
          changeset: MyContext.change_resource(resource),
          editing_role: :spectator  # Will be updated after presence tracking
        )

        if connected?(socket) do
          # Subscribe to presence changes and form updates
          PresenceHelpers.subscribe_to_editing(:resource, id)
          Events.subscribe_to_resource_form(form_key)

          # Track this editing session (pass user struct, not metadata map)
          current_user = socket.assigns.current_user
          PresenceHelpers.track_editing_session(:resource, id, socket, current_user)

          # Determine our role (owner or spectator) - returns tuple
          case PresenceHelpers.get_editing_role(:resource, id, socket.id, current_user.id) do
            {:owner, _presences} ->
              socket = assign(socket, editing_role: :owner)
              {:ok, socket}

            {:spectator, _owner_meta, _presences} ->
              socket = assign(socket, editing_role: :spectator)
              {:ok, socket}
          end
        else
          {:ok, socket}
        end
      end

      @impl true
      def handle_event("validate", %{"resource" => params}, socket) do
        # Only owners can edit
        if socket.assigns.editing_role == :owner do
          changeset = MyContext.change_resource(socket.assigns.resource, params)

          # Broadcast changes to spectators via PubSub
          Events.broadcast_resource_form_change("resource_#{socket.assigns.resource.id}", %{
            changeset_params: params
          })

          {:noreply, assign(socket, changeset: changeset, has_unsaved_changes: true)}
        else
          # Spectators can't edit
          {:noreply, socket}
        end
      end

      # Handle presence_diff - someone joined or left
      @impl true
      def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
        resource_id = socket.assigns.resource.id
        current_user = socket.assigns.current_user

        # Recalculate our role
        case PresenceHelpers.get_editing_role(:resource, resource_id, socket.id, current_user.id) do
          {:owner, _presences} ->
            old_role = socket.assigns.editing_role
            socket = assign(socket, editing_role: :owner)

            # If we were promoted from spectator to owner
            socket = if old_role == :spectator do
              put_flash(socket, :info, "You are now editing this resource")
            else
              socket
            end

            {:noreply, socket}

          {:spectator, _owner_meta, _presences} ->
            socket = assign(socket, editing_role: :spectator)
            {:noreply, socket}
        end
      end

      # Handle form changes broadcast from owner
      @impl true
      def handle_info({:resource_form_change, form_key, payload, _source}, socket) do
        if form_key == "resource_#{socket.assigns.resource.id}" && socket.assigns.editing_role == :spectator do
          case Map.get(payload, :changeset_params) do
            params when not is_nil(params) ->
              changeset = MyContext.change_resource(socket.assigns.resource, params)
              {:noreply, assign(socket, changeset: changeset)}

            _ ->
              {:noreply, socket}
          end
        else
          {:noreply, socket}
        end
      end

      @impl true
      def handle_event("save", %{"resource" => params}, socket) do
        # Only owners can save
        if socket.assigns.editing_role == :owner do
          case MyContext.update_resource(socket.assigns.resource, params) do
            {:ok, resource} ->
              {:noreply,
               socket
               |> put_flash(:info, "Resource updated successfully")
               |> redirect(to: ~p"/admin/resources/#{resource.id}")}

            {:error, changeset} ->
              {:noreply, assign(socket, changeset: changeset)}
          end
        else
          {:noreply, put_flash(socket, :error, "Only the owner can save changes")}
        end
      end

      # No manual cleanup needed - Phoenix.Presence handles it automatically
    end

## Events System

### Creating an Events Module

File: `lib/phoenix_kit/entities/events.ex`

    defmodule PhoenixKit.Entities.Events do
      @moduledoc """
      PubSub event broadcasting for entity lifecycle events.
      """

      alias PhoenixKit.PubSub.Manager

      @entity_topic "phoenix_kit:entities"
      @entity_data_topic_prefix "phoenix_kit:entity_data:"

      # Subscription functions
      def subscribe_to_entities do
        Manager.subscribe(@entity_topic)
      end

      def subscribe_to_entity_data(entity_id) do
        Manager.subscribe(@entity_data_topic_prefix <> to_string(entity_id))
      end

      # Broadcasting functions
      def broadcast_entity_created(entity_id) do
        Manager.broadcast(@entity_topic, {:entity_created, entity_id})
      end

      def broadcast_entity_updated(entity_id) do
        Manager.broadcast(@entity_topic, {:entity_updated, entity_id})
      end

      def broadcast_entity_deleted(entity_id) do
        Manager.broadcast(@entity_topic, {:entity_deleted, entity_id})
      end

      def broadcast_data_created(entity_id, data_id) do
        Manager.broadcast(@entity_data_topic_prefix <> to_string(entity_id),
          {:data_created, entity_id, data_id})
      end

      def broadcast_data_updated(entity_id, data_id) do
        Manager.broadcast(@entity_data_topic_prefix <> to_string(entity_id),
          {:data_updated, entity_id, data_id})
      end
    end

### Adding Event Broadcasting to Context Functions

Pattern: Pipe the result through a notification function.

    defmodule PhoenixKit.Entities do
      alias PhoenixKit.Entities.Events

      def create_entity(attrs) do
        %Entity{}
        |> changeset(attrs)
        |> repo().insert()
        |> notify_entity_event(:created)
      end

      def update_entity(entity, attrs) do
        entity
        |> changeset(attrs)
        |> repo().update()
        |> notify_entity_event(:updated)
      end

      def delete_entity(entity) do
        entity
        |> repo().delete()
        |> notify_entity_event(:deleted)
      end

      defp notify_entity_event({:ok, %Entity{} = entity}, :created) do
        Events.broadcast_entity_created(entity.id)
        {:ok, entity}
      end

      defp notify_entity_event({:ok, %Entity{} = entity}, :updated) do
        Events.broadcast_entity_updated(entity.id)
        {:ok, entity}
      end

      defp notify_entity_event({:ok, %Entity{} = entity}, :deleted) do
        Events.broadcast_entity_deleted(entity.id)
        {:ok, entity}
      end

      defp notify_entity_event(result, _event), do: result
    end

## Collaborative Editing with Phoenix.Presence

### Understanding the "First in List = Owner" Pattern

PhoenixKit uses a distributed presence-based ownership model:

1. Multiple users can open the same form simultaneously
2. Phoenix.Presence tracks all users (sorted by `joined_at` timestamp)
3. First person in the list = owner (can edit)
4. Everyone else = spectators (read-only, see real-time updates)
5. When owner leaves, Phoenix.Presence automatically removes them and broadcasts `presence_diff`
6. Next person in line becomes owner automatically (within 5 seconds)

No locks, no manual cleanup, no race conditions.

### Tracking Editing Sessions

Use `PresenceHelpers` to track editing sessions:

    alias PhoenixKit.Entities.PresenceHelpers

    # In mount/3
    if connected?(socket) do
      current_user = socket.assigns.current_user

      # Subscribe to presence changes
      PresenceHelpers.subscribe_to_editing(:entity, entity_id)

      # Track this editing session (pass user struct, not metadata map)
      PresenceHelpers.track_editing_session(:entity, entity_id, socket, current_user)

      # Determine our role (returns tuple, not bare atom)
      case PresenceHelpers.get_editing_role(:entity, entity_id, socket.id, current_user.id) do
        {:owner, _presences} ->
          assign(socket, editing_role: :owner)

        {:spectator, _owner_meta, _presences} ->
          assign(socket, editing_role: :spectator)
      end
    end

### Broadcasting Changes to Spectators

Key Concept: Owner broadcasts form changes via PubSub Events, spectators receive and apply them.

    # In handle_event("validate")
    if socket.assigns.editing_role == :owner do
      changeset = MyContext.change_resource(resource, params)

      # Broadcast changes to spectators via PubSub Events system
      Events.broadcast_entity_form_change("entity_#{entity_id}", %{
        changeset_params: params,
        last_updated: System.system_time(:millisecond)
      })

      {:noreply, assign(socket, changeset: changeset)}
    else
      # Spectators can't edit
      {:noreply, socket}
    end

### Handling Presence Changes

Handle `presence_diff` to react to ownership changes:

    @impl true
    def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
      entity_id = socket.assigns.entity.id
      current_user = socket.assigns.current_user

      # Recalculate our role (returns tuple)
      case PresenceHelpers.get_editing_role(:entity, entity_id, socket.id, current_user.id) do
        {:owner, _presences} ->
          old_role = socket.assigns.editing_role
          socket = assign(socket, editing_role: :owner)

          # Notify user if promoted to owner
          socket = if old_role == :spectator do
            put_flash(socket, :info, "You are now editing")
          else
            socket
          end

          {:noreply, socket}

        {:spectator, _owner_meta, _presences} ->
          socket = assign(socket, editing_role: :spectator)
          {:noreply, socket}
      end
    end

    # Handle form changes broadcast from owner
    @impl true
    def handle_info({:entity_form_change, form_key, payload, _source}, socket) do
      if form_key == "entity_#{socket.assigns.entity.id}" && socket.assigns.editing_role == :spectator do
        case Map.get(payload, :changeset_params) do
          params when not is_nil(params) ->
            changeset = MyContext.change_resource(socket.assigns.entity, params)
            {:noreply, assign(socket, changeset: changeset)}

          _ ->
            {:noreply, socket}
        end
      else
        {:noreply, socket}
      end
    end

### Displaying Editor Information in Templates

Show who else is editing and your role:

    <%!-- Show editing role badge --%>
    <div class="flex items-center gap-2 mb-4">
      <%= if @editing_role == :owner do %>
        <span class="badge badge-success">You are editing</span>
      <% else %>
        <span class="badge badge-warning">Read-only (someone else editing)</span>
      <% end %>

      <%!-- Show other editors --%>
      <%= if length(@presences) > 1 do %>
        <span class="text-sm text-gray-600">
          <%= length(@presences) - 1 %> other <%= if length(@presences) == 2, do: "person", else: "people" %>
        </span>
      <% end %>
    </div>

    <%!-- Disable form fields for spectators --%>
    <.input
      field={@form[:name]}
      label="Name"
      disabled={@editing_role == :spectator}
    />

    # In mount/3 or handle_info/2
    presences = PresenceHelpers.get_sorted_presences(:entity, entity_id)
    assign(socket, presences: presences)

### No Manual Cleanup Required

Phoenix.Presence automatically cleans up when your LiveView process terminates:

    # ❌ OLD WAY (no longer needed):
    @impl true
    def terminate(_reason, socket) do
      Presence.untrack("editor:entity:#{entity_id}:")
      Presence.clear_data("state:entity:#{entity_id}")
      :ok
    end

    # ✅ NEW WAY (automatic cleanup):
    # No terminate/2 callback needed!
    # Phoenix.Presence removes you automatically when LiveView process dies

## On Mount Hooks

### Creating Centralized Subscriptions

File: `lib/phoenix_kit_web/live/modules/entities/hooks.ex`

    defmodule PhoenixKitWeb.Live.Modules.Entities.Hooks do
      @moduledoc """
      LiveView hooks for entity module pages.
      Provides common setup and subscriptions for all entity-related LiveViews.
      """

      import Phoenix.LiveView
      alias PhoenixKit.Entities.Events

      def on_mount(:default, _params, _session, socket) do
        if connected?(socket) do
          Events.subscribe_to_entities()
        end

        {:cont, socket}
      end
    end

### Using Hooks in LiveViews

    defmodule PhoenixKitWeb.Live.Modules.Entities.EntitiesLive do
      use PhoenixKitWeb, :live_view

      # Add this line to use the hook
      on_mount PhoenixKitWeb.Live.Modules.Entities.Hooks

      # Now you don't need to call Events.subscribe_to_entities() in mount/3
      # It's handled automatically by the hook!

      @impl true
      def mount(_params, _session, socket) do
        entities = Entities.list_entities()
        {:ok, assign(socket, :entities, entities)}
      end

      # Still need handle_info callbacks
      @impl true
      def handle_info({:entity_created, _entity_id}, socket) do
        {:noreply, assign(socket, :entities, Entities.list_entities())}
      end
    end

## Common Patterns

### Pattern 1: List Page with Real-time Updates

Reference: `lib/phoenix_kit_web/live/modules/entities/entities.ex`

    defmodule MyApp.MyResourcesLive do
      use MyAppWeb, :live_view
      on_mount MyAppWeb.Live.Hooks  # Centralized subscriptions

      @impl true
      def mount(_params, _session, socket) do
        resources = load_resources()
        {:ok, assign(socket, :resources, resources)}
      end

      @impl true
      def handle_info({:resource_created, _id}, socket) do
        {:noreply, assign(socket, :resources, load_resources())}
      end

      @impl true
      def handle_info({:resource_updated, _id}, socket) do
        {:noreply, assign(socket, :resources, load_resources())}
      end

      @impl true
      def handle_info({:resource_deleted, _id}, socket) do
        {:noreply, assign(socket, :resources, load_resources())}
      end

      defp load_resources do
        MyContext.list_resources()
        |> Enum.sort_by(& &1.inserted_at, {:desc, DateTime})
      end
    end

### Pattern 2: Detail Page with Remote Updates

Reference: `lib/phoenix_kit_web/live/modules/entities/data_navigator.ex`

    defmodule MyApp.MyResourceDetailLive do
      use MyAppWeb, :live_view

      @impl true
      def mount(%{"id" => id}, _session, socket) do
        resource = MyContext.get_resource!(id)

        if connected?(socket) do
          Events.subscribe_to_resource(id)
        end

        {:ok, assign(socket, :resource, resource)}
      end

      @impl true
      def handle_info({:resource_updated, resource_id}, socket) do
        if resource_id == socket.assigns.resource.id do
          resource = MyContext.get_resource!(resource_id)

          # Check if resource was archived/deleted
          if resource.status == "archived" do
            {:noreply,
             socket
             |> put_flash(:warning, "Resource was archived in another session")
             |> redirect(to: ~p"/admin/resources")}
          else
            # Update with fresh data
            {:noreply, assign(socket, :resource, resource)}
          end
        else
          {:noreply, socket}
        end
      end

      @impl true
      def handle_info({:resource_deleted, resource_id}, socket) do
        if resource_id == socket.assigns.resource.id do
          {:noreply,
           socket
           |> put_flash(:error, "Resource was deleted in another session")
           |> redirect(to: ~p"/admin/resources")}
        else
          {:noreply, socket}
        end
      end
    end

### Pattern 3: Collaborative Form Editing

Reference: `lib/phoenix_kit_web/live/modules/entities/entity_form.ex`

    defmodule MyApp.MyResourceFormLive do
      use MyAppWeb, :live_view

      alias PhoenixKit.Entities.PresenceHelpers

      @impl true
      def mount(%{"id" => id}, _session, socket) do
        resource = MyContext.get_resource!(id)
        changeset = MyContext.change_resource(resource)

        socket = assign(socket,
          resource: resource,
          changeset: changeset,
          editing_role: :spectator,
          has_unsaved_changes: false,
          presences: []
        )

        if connected?(socket) do
          # Subscribe to presence changes
          PresenceHelpers.subscribe_to_editing(:resource, id)

          # Track this editing session (pass user struct)
          current_user = socket.assigns.current_user
          PresenceHelpers.track_editing_session(:resource, id, socket, current_user)

          # Determine our role (returns tuple)
          {editing_role, presences} =
            case PresenceHelpers.get_editing_role(:resource, id, socket.id, current_user.id) do
              {:owner, presences} -> {:owner, presences}
              {:spectator, _owner_meta, presences} -> {:spectator, presences}
            end

          # If we're a spectator, sync with owner's current state
          socket = if editing_role == :spectator do
            case PresenceHelpers.get_lock_owner(:resource, id) do
              %{form_state: %{changeset_params: params}} when params != %{} ->
                changeset = MyContext.change_resource(resource, params)
                assign(socket, changeset: changeset, has_unsaved_changes: true)

              _ ->
                socket
            end
          else
            socket
          end

          socket = assign(socket,
            editing_role: editing_role,
            presences: presences
          )

          {:ok, socket}
        else
          {:ok, socket}
        end
      end

      @impl true
      def handle_event("validate", %{"resource" => params}, socket) do
        # Only owners can edit
        if socket.assigns.editing_role == :owner do
          changeset = MyContext.change_resource(socket.assigns.resource, params)

          # Broadcast changes to spectators via PubSub Events
          Events.broadcast_resource_form_change("resource_#{socket.assigns.resource.id}", %{
            changeset_params: params,
            last_updated: System.system_time(:millisecond)
          })

          {:noreply, assign(socket, changeset: changeset, has_unsaved_changes: true)}
        else
          # Spectators can't edit
          {:noreply, socket}
        end
      end

      @impl true
      def handle_event("save", %{"resource" => params}, socket) do
        # Only owners can save
        if socket.assigns.editing_role == :owner do
          case MyContext.update_resource(socket.assigns.resource, params) do
            {:ok, resource} ->
              {:noreply,
               socket
               |> put_flash(:info, "Resource updated successfully")
               |> redirect(to: ~p"/admin/resources/#{resource.id}")}

            {:error, changeset} ->
              {:noreply, assign(socket, changeset: changeset)}
          end
        else
          {:noreply, put_flash(socket, :error, "Only the owner can save")}
        end
      end

      # Handle presence_diff - someone joined or left
      @impl true
      def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
        resource_id = socket.assigns.resource.id
        current_user = socket.assigns.current_user
        old_role = socket.assigns.editing_role

        # Recalculate our role (returns tuple)
        {editing_role, presences} =
          case PresenceHelpers.get_editing_role(:resource, resource_id, socket.id, current_user.id) do
            {:owner, presences} -> {:owner, presences}
            {:spectator, _owner_meta, presences} -> {:spectator, presences}
          end

        socket = assign(socket, editing_role: editing_role, presences: presences)

        # If we were promoted to owner
        socket = if old_role == :spectator && editing_role == :owner do
          put_flash(socket, :info, "You are now editing this resource")
        else
          socket
        end

        {:noreply, socket}
      end

      # Handle form changes broadcast from owner
      @impl true
      def handle_info({:resource_form_change, form_key, payload, _source}, socket) do
        if form_key == "resource_#{socket.assigns.resource.id}" && socket.assigns.editing_role == :spectator do
          case Map.get(payload, :changeset_params) do
            params when not is_nil(params) ->
              changeset = MyContext.change_resource(socket.assigns.resource, params)
              {:noreply, assign(socket, changeset: changeset)}

            _ ->
              {:noreply, socket}
          end
        else
          {:noreply, socket}
        end
      end

      # No terminate/2 needed - Phoenix.Presence cleans up automatically!
    end

## Troubleshooting

### Issue: Changes Not Appearing for Spectators

Symptom: Owner types but spectators don't see updates.

Causes:

1. Not subscribed to presence changes
2. Not updating presence metadata
3. `handle_info/2` for `presence_diff` not implemented

Solution:

    # In mount/3 - verify subscription
    if connected?(socket) do
      PresenceHelpers.subscribe_to_editing(:resource, id)
    end

    # In handle_event("validate") - verify broadcasting
    if socket.assigns.editing_role == :owner do
      Events.broadcast_resource_form_change("resource_#{id}", %{
        changeset_params: params
      })
    end

    # Verify handle_info for presence_diff
    @impl true
    def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
      # Recalculate role when someone joins/leaves
      current_user = socket.assigns.current_user

      case PresenceHelpers.get_editing_role(:resource, id, socket.id, current_user.id) do
        {:owner, _presences} -> assign(socket, editing_role: :owner)
        {:spectator, _owner_meta, _presences} -> assign(socket, editing_role: :spectator)
      end
      |> then(&{:noreply, &1})
    end

    # Verify handle_info for form changes
    @impl true
    def handle_info({:resource_form_change, form_key, payload, _source}, socket) do
      if form_key == "resource_#{id}" && socket.assigns.editing_role == :spectator do
        case Map.get(payload, :changeset_params) do
          params when not is_nil(params) ->
            changeset = MyContext.change_resource(resource, params)
            {:noreply, assign(socket, changeset: changeset)}
          _ ->
            {:noreply, socket}
        end
      else
        {:noreply, socket}
      end
    end

### Issue: Wrong Person is Owner

Symptom: User B is owner but User A joined first.

Cause: Phoenix.Presence sorts by `joined_at` timestamp internally.

Solution: This should work automatically. If not:

    # Debug: Check presence list order
    presences = PresenceHelpers.get_sorted_presences(:resource, id)
    IO.inspect(presences, label: "Presences (sorted by joined_at)")

    # Verify your socket_id matches
    editing_role = PresenceHelpers.get_editing_role(:resource, id, socket.id, current_user.id)
    IO.inspect({socket.id, editing_role}, label: "My socket_id and role")

### Issue: Promoted to Owner But Can't Edit

Symptom: User promoted to owner but form fields still disabled.

Cause: Not updating UI when role changes.

Solution:

    # In handle_info(%{event: "presence_diff"})
    editing_role = PresenceHelpers.get_editing_role(:resource, id, socket.id, current_user.id)
    old_role = socket.assigns.editing_role

    socket = assign(socket, editing_role: editing_role)

    # Show notification
    socket = if old_role == :spectator && editing_role == :owner do
      put_flash(socket, :info, "You are now editing")
    else
      socket
    end

    # Template should react to @editing_role
    # <.input field={@form[:name]} disabled={@editing_role == :spectator} />

### Issue: Late Joiner Doesn't See Owner's Changes

Symptom: User B joins but sees blank form, not User A's work-in-progress.

Cause: Not syncing with owner's metadata on mount.

Solution:

    # In mount/3, after tracking presence
    if connected?(socket) do
      current_user = socket.assigns.current_user

      PresenceHelpers.track_editing_session(:resource, id, socket, current_user)

      {editing_role, _presences} =
        case PresenceHelpers.get_editing_role(:resource, id, socket.id, current_user.id) do
          {:owner, presences} -> {:owner, presences}
          {:spectator, _owner_meta, presences} -> {:spectator, presences}
        end

      # Sync with owner if we're a spectator
      socket = if editing_role == :spectator do
        case PresenceHelpers.get_lock_owner(:resource, id) do
          %{form_state: %{changeset_params: params}} when params != %{} ->
            changeset = MyContext.change_resource(resource, params)
            assign(socket, changeset: changeset)
          _ ->
            socket
        end
      else
        socket
      end

      {:ok, assign(socket, editing_role: editing_role)}
    end

### Issue: Presence Timeout (5 seconds feels slow)

Symptom: When owner leaves, it takes 5 seconds before next person promoted.

Explanation: This is intentional and configurable in `lib/phoenix_kit/entities/presence.ex`:

    use Phoenix.Presence,
      otp_app: :phoenix_kit,
      pubsub_server: :phoenix_kit_internal_pubsub,
      presence_opts: [timeout: 5_000]  # ← Adjust this value

Trade-offs:

- Lower timeout (1-2 seconds): Faster promotion, but more false positives from network hiccups
- Higher timeout (10+ seconds): More reliable, but slower role transitions

Recommended: Keep at 5 seconds for production use.

## Best Practices

### 1. Always Use PresenceHelpers, Not Phoenix.Presence Directly

    # ✅ Good - use PresenceHelpers
    alias PhoenixKit.Entities.PresenceHelpers

    PresenceHelpers.track_editing_session(:resource, id, socket, metadata)
    editing_role = PresenceHelpers.get_editing_role(:resource, id, socket.id, current_user.id)

    # ❌ Bad - don't use Phoenix.Presence directly
    alias PhoenixKit.Entities.Presence
    Presence.track(socket.id, topic, socket.id, metadata)

### 2. Broadcast State Changes via PubSub Events

    # ✅ Good - broadcast changes via Events system
    Events.broadcast_resource_form_change("resource_#{id}", %{
      changeset_params: params,
      last_updated: System.system_time(:millisecond)
    })

    # ❌ Bad - don't try to manually update presence metadata
    # (Presence metadata is for user info, not real-time form state)

### 3. Use on_mount Hooks for Repeated Subscriptions

    # Instead of repeating this in every LiveView:
    if connected?(socket) do
      Events.subscribe_to_entities()
    end

    # Create a hook and use it once
    on_mount MyAppWeb.Live.Hooks

### 4. No Manual Cleanup Needed in terminate/2

    # ❌ OLD WAY (no longer needed):
    @impl true
    def terminate(_reason, socket) do
      Presence.untrack("editor:resource:#{id}:")
      Presence.clear_data("state:resource:#{id}")
      :ok
    end

    # ✅ NEW WAY (automatic cleanup):
    # No terminate/2 callback needed!
    # Phoenix.Presence automatically cleans up when LiveView dies

### 5. Handle Resource Deletion in handle_info

    def handle_info({:resource_deleted, resource_id}, socket) do
      if resource_id == socket.assigns.resource.id do
        {:noreply,
         socket
         |> put_flash(:error, "Resource deleted in another session")
         |> redirect(to: ~p"/admin/resources")}
      else
        {:noreply, socket}
      end
    end

### 6. Always Disable Form Fields for Spectators

    # In templates, bind disabled state to editing_role
    <.input
      field={@form[:name]}
      label="Name"
      disabled={@editing_role == :spectator}
    />

    # In handle_event, guard against spectator edits
    def handle_event("validate", params, socket) do
      if socket.assigns.editing_role == :owner do
        # Process changes
      else
        # Reject changes
        {:noreply, socket}
      end
    end

### 7. Handle Both Presence Changes AND Form Broadcasts

    # Handle presence_diff for role changes
    def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
      current_user = socket.assigns.current_user

      case PresenceHelpers.get_editing_role(:resource, id, socket.id, current_user.id) do
        {:owner, _presences} -> assign(socket, editing_role: :owner)
        {:spectator, _owner_meta, _presences} -> assign(socket, editing_role: :spectator)
      end
      |> then(&{:noreply, &1})
    end

    # Handle form changes broadcast from owner
    def handle_info({:resource_form_change, form_key, payload, _source}, socket) do
      if form_key == "resource_#{id}" && socket.assigns.editing_role == :spectator do
        case Map.get(payload, :changeset_params) do
          params when not is_nil(params) ->
            changeset = MyContext.change_resource(resource, params)
            {:noreply, assign(socket, changeset: changeset)}
          _ ->
            {:noreply, socket}
        end
      else
        {:noreply, socket}
      end
    end

## Architecture Summary

    ┌──────────────────────────────────────────────────────────────────────┐
    │  LiveView Page (entity_form.ex)                                      │
    │                                                                       │
    │  mount/3:                                                             │
    │    - Subscribe to presence changes via PresenceHelpers               │
    │    - Track editing session with metadata                             │
    │    - Determine role (:owner or :spectator)                           │
    │    - Sync with owner's metadata if spectator                         │
    │                                                                       │
    │  handle_event("validate"):                                           │
    │    - Guard: Only :owner can edit                                     │
    │    - Update changeset                                                │
    │    - Update presence metadata (spectators see via presence_diff)     │
    │                                                                       │
    │  handle_info(%{event: "presence_diff"}):                             │
    │    - Recalculate role (someone joined/left)                          │
    │    - Notify if promoted to :owner                                    │
    │    - Sync with owner's metadata if :spectator                        │
    │                                                                       │
    │  No terminate/2 needed!                                              │
    │    - Phoenix.Presence auto-cleans up                                 │
    └──────────────────────────────────────────────────────────────────────┘
                                  ↓              ↑
                                  ↓              ↑
            ┌─────────────────────────────────────────────────────┐
            │  PresenceHelpers (Utility Module)                   │
            │                                                      │
            │  - track_editing_session(type, id, socket, user)    │
            │  - get_editing_role(type, id, socket_id, user_id)   │
            │    → {:owner, presences} or {:spectator, meta, presences} │
            │  - get_lock_owner(type, id)                         │
            │  - get_sorted_presences(type, id)                   │
            │  - get_spectators(type, id)                         │
            │  - subscribe_to_editing(type, id)                   │
            └─────────────────────────────────────────────────────┘
                                  ↓              ↑
                                  ↓              ↑
            ┌─────────────────────────────────────────────────────┐
            │  Phoenix.Presence (CRDT-based)                       │
            │                                                      │
            │  - Distributed presence tracking                     │
            │  - Automatic cleanup (5s timeout)                   │
            │  - Metadata storage (changeset_params)              │
            │  - FIFO ordering by joined_at                       │
            │  - Broadcasts presence_diff events                   │
            └─────────────────────────────────────────────────────┘
                                  ↓              ↑
                                  ↓              ↑
            ┌─────────────────────────────────────────────────────┐
            │  Events (PubSub for Lifecycle Events)                │
            │                                                      │
            │  - subscribe_to_entities()                           │
            │  - broadcast_entity_created/updated/deleted()        │
            │  - broadcast_data_created/updated/deleted()          │
            └─────────────────────────────────────────────────────┘
                                  ↓              ↑
                                  ↓              ↑
            ┌─────────────────────────────────────────────────────┐
            │  Context (entities.ex)                               │
            │                                                      │
            │  - create_entity/update_entity/delete_entity         │
            │  - notify_entity_event() after DB operations         │
            └─────────────────────────────────────────────────────┘

    Key Concepts:
      • First in list = owner (can edit)
      • Everyone else = spectator (read-only, sees real-time updates)
      • Owner's metadata contains changeset_params
      • Spectators sync with owner's metadata via presence_diff
      • Automatic promotion when owner leaves (within 5 seconds)
      • No manual locks, no manual cleanup, no race conditions

## Reference Files

### Core Real-time Systems

- Events System: `lib/phoenix_kit/entities/events.ex`

  - PubSub broadcasting for entity/data lifecycle events
  - Subscribe/broadcast functions for real-time updates

- Phoenix.Presence: `lib/phoenix_kit/entities/presence.ex`

  - CRDT-based distributed presence tracking
  - 5-second timeout configuration
  - Automatic cleanup when LiveView processes terminate

- PresenceHelpers: `lib/phoenix_kit/entities/presence_helpers.ex`

  - High-level utilities for collaborative editing
  - "First in list = owner" logic
  - Metadata management for state synchronization

- SimplePresence (Admin Only): `lib/phoenix_kit/admin/simple_presence.ex`

  - Lightweight session tracking for admin dashboard
  - NOT used for collaborative editing

### LiveView Examples

- Hooks: `lib/phoenix_kit_web/live/modules/entities/hooks.ex`

  - Centralized subscriptions with `on_mount`

- List Page: `lib/phoenix_kit_web/live/modules/entities/entities.ex`

  - Real-time list updates via Events system

- Detail Page: `lib/phoenix_kit_web/live/modules/entities/data_navigator.ex`

  - Real-time detail updates and deletion handling

- Collaborative Form (Entity): `lib/phoenix_kit_web/live/modules/entities/entity_form.ex`

  - Full collaborative editing implementation
  - Owner/spectator roles
  - Real-time state synchronization

- Collaborative Form (Data): `lib/phoenix_kit_web/live/modules/entities/data_form.ex`

  - Data record collaborative editing
  - Same patterns as entity_form.ex

## Next Steps

1. Read the reference files to see real implementations
2. Copy patterns that match your use case
3. Test with multiple browser tabs to verify real-time updates
4. Check the troubleshooting section if issues arise
5. Follow best practices for clean, maintainable code

← Previous Page AWS Email Setup Guide
Next Page → .phk Blogging Format (Current Implementation)