Repository: phoenixframework/phoenix
Pull request #4743 (2022-04-01 19:25:01)

Update presence client docs
* Raises a custom ArgumentError when init/1 is missing on a presence module when handle_metas/4 is present.
* Adds callback docs to init/1 and handle_metas/4
* Updates references in docs


Patch:
diff --git a/assets/js/phoenix/longpoll.js b/assets/js/phoenix/longpoll.js
index ef33901d7..43282204b 100644
--- a/assets/js/phoenix/longpoll.js
+++ b/assets/js/phoenix/longpoll.js
@@ -32,14 +32,14 @@ export default class LongPoll {
     return Ajax.appendParams(this.pollEndpoint, {token: this.token})
   }
 
-  closeAndRetry(){
-    this.close()
+  closeAndRetry(code, reason, wasClean){
+    this.close(code, reason, wasClean)
     this.readyState = SOCKET_STATES.connecting
   }
 
   ontimeout(){
     this.onerror("timeout")
-    this.closeAndRetry()
+    this.closeAndRetry(1005, "timeout", false)
   }
 
   poll(){
@@ -85,17 +85,17 @@ export default class LongPoll {
           break
         case 410:
           this.readyState = SOCKET_STATES.open
-          this.onopen()
+          this.onopen({})
           this.poll()
           break
         case 403:
-          this.onerror()
-          this.close()
+          this.onerror(403)
+          this.close(1008, "forbidden", false)
           break
         case 0:
         case 500:
-          this.onerror()
-          this.closeAndRetry()
+          this.onerror(500)
+          this.closeAndRetry(1011, "internal server error", 500)
           break
         default: throw new Error(`unhandled poll status ${status}`)
       }
@@ -106,13 +106,18 @@ export default class LongPoll {
     Ajax.request("POST", this.endpointURL(), "application/json", body, this.timeout, this.onerror.bind(this, "timeout"), (resp) => {
       if(!resp || resp.status !== 200){
         this.onerror(resp && resp.status)
-        this.closeAndRetry()
+        this.closeAndRetry(1011, "internal server error", false)
       }
     })
   }
 
-  close(_code, _reason){
+  close(code, reason, wasClean){
     this.readyState = SOCKET_STATES.closed
-    this.onclose()
+    this.onclose(
+      new CloseEvent(
+        'close',
+        Object.assign({ code: 1000, reason: undefined, wasClean: true }, { code, reason, wasClean }),
+      ),
+    )
   }
 }
diff --git a/lib/phoenix/presence.ex b/lib/phoenix/presence.ex
index 08ade533c..396dc7913 100644
--- a/lib/phoenix/presence.ex
+++ b/lib/phoenix/presence.ex
@@ -126,9 +126,10 @@ defmodule Phoenix.Presence do
   Presence is great for external clients, such as JavaScript applications, but
   it can also be used from an Elixir client process to keep track of presence
   changes as they happen on the server. This can be accomplished by implementing
-  the optional `init/1` and handle_metas/4` callback on your presence module.
-  For example, the following callback receives presence metadata changes,
-  and broadcasts to other Elixir processes about users joining and leaving:
+  the optional [`init/1`](`c:init/1`) and [`handle_metas/4`](`c:handle_metas/4`)
+  callbacks on your presence module. For example, the following callback
+  receives presence metadata changes, and broadcasts to other Elixir processes
+  about users joining and leaving:
 
       defmodule MyApp.Presence do
         use Phoenix.Presence,
@@ -144,7 +145,7 @@ defmodule Phoenix.Presence do
           # event to all subscribers
           for {user_id, presence} <- joins do
             user_data = %{user: presence.user, metas: Map.fetch!(presences, user_id)}
-            msg = {LiveBeats.PresenceClient, {:join, user_data}}
+            msg = {MyApp.PresenceClient, {:join, user_data}}
             Phoenix.PubSub.local_broadcast(MyApp.PubSub, topic, msg)
           end
 
@@ -158,7 +159,7 @@ defmodule Phoenix.Presence do
               end
 
             user_data = %{user: presence.user, metas: metas}
-            msg = {LiveBeats.PresenceClient, {:leave, user_data}}
+            msg = {MyApp.PresenceClient, {:leave, user_data}}
             Phoenix.PubSub.local_broadcast(MyApp.PubSub, topic, msg)
           end
 
@@ -332,8 +333,17 @@ defmodule Phoenix.Presence do
   """
   @callback fetch(topic, presences) :: presences
 
+  @doc """
+  Initializes the presence client state.
+
+  Invoked when your presence module starts, allows dynamically
+  providing initial state for handling presence metadata.
+  """
   @callback init(state :: term) :: {:ok, new_state :: term}
 
+  @doc """
+  Receives presence metadata changes.
+  """
   @callback handle_metas(topic :: String.t(), diff :: map(), presences :: map(), state :: term) ::
               {:ok, term}
 
@@ -458,6 +468,19 @@ defmodule Phoenix.Presence do
 
     client_state =
       if function_exported?(module, :handle_metas, 4) do
+        unless function_exported?(module, :init, 1) do
+          raise ArgumentError, """
+          missing #{inspect(module)}.init/1 callback for client state
+
+          When you implement the handle_metas/4 callback, you must also
+          implement init/1. For example, add the following to
+          #{inspect(module)}:
+
+          def init(_opts), do: {:ok, %{}}
+
+          """
+        end
+
         case module.init(%{}) do
           {:ok, client_state} ->
             client_state


Test patch:
diff --git a/test/phoenix/presence_test.exs b/test/phoenix/presence_test.exs
index 69af36bf5..8b24f09c2 100644
--- a/test/phoenix/presence_test.exs
+++ b/test/phoenix/presence_test.exs
@@ -27,6 +27,22 @@ defmodule Phoenix.PresenceTest do
     end
   end
 
+  defmodule MetasMissingInitPresence do
+    use Phoenix.Presence, otp_app: :phoenix
+
+    def init_presence do
+      Phoenix.Presence.init({
+        __MODULE__,
+        __MODULE__.TaskSupervisor,
+        PresPub
+      })
+    end
+
+    def handle_metas(_topic, _diff, _presences, _state) do
+      raise ArgumentError, "should not be called due to missing init/1"
+    end
+  end
+
   Application.put_env(:phoenix, MyPresence, pubsub_server: PresPub)
   Application.put_env(:phoenix, MetasPresence, pubsub_server: PresPub)
 
@@ -233,6 +249,14 @@ defmodule Phoenix.PresenceTest do
   end
 
   describe "Presence behaviour when handle_metas is defined" do
+    test "raises when missing init/1" do
+      assert_raise ArgumentError,
+                   ~r|missing Phoenix.PresenceTest.MetasMissingInitPresence.init/1 callback for client state|,
+                   fn ->
+                     MetasMissingInitPresence.init_presence()
+                   end
+    end
+
     test "async_merge/2 creates new topic and metas",
          %{topic: topic} = config do
       Phoenix.PubSub.subscribe(config.pubsub, topic)