Skip to content

Commit 98dafc7

Browse files
committed
fix(v1.0.0): complete v1.0.0 release preparation
Breaking Changes: - Removed default handle_async/3 implementation in :reducer macro - Previously all reducers had a no-op handle_async/3 by default - This caused async: true actions to be silently ignored - Now handle_async/3 is only exported if explicitly defined - Actions with async: true will route to handle_action/2 if no handle_async/3 API Changes: - dispatch_async/4 is now a convenience alias that adds async: true to meta - dispatch_async/4 returns :ok (not {:ok, cancel_fn}) - Both dispatch/4 and dispatch_async/4 are fire-and-forget (async) Version Updates: - Updated version to 1.0.0 in mix.exs - Updated README installation instructions to ~> 1.0 - Updated CHANGELOG release date to 2025-10-31 - Removed deprecated Redux modules from docs configuration Documentation Updates: - Fixed dispatch callback signatures in examples (3 arguments required) - Updated all docs to reflect dispatch_async as convenience alias - Clarified that async: true routes to handle_async/3 if available - Added notes about default handle_async removal Test Updates: - Fixed dispatch_test.exs to expect :ok instead of {:ok, cancel_fn} - Increased Process.sleep to 50ms for async action processing - All 148 tests passing Fixes: - Fixed async actions being silently ignored when reducer lacks handle_async/3 - Fixed dispatch callback arguments in Example 03 (3 args required) - Fixed test expectations for dispatch_async return value
1 parent 63d7ae5 commit 98dafc7

File tree

7 files changed

+77
-128
lines changed

7 files changed

+77
-128
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased] - v1.0.0
8+
## [1.0.0] - 2025-10-31
99

1010
### Breaking Changes
1111

CLAUDE.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ The library is organized into several logical groups:
9999

100100
**Redux Store API (v1.0.0)** - SessionProcess IS the Redux store:
101101
- `dispatch/4` - Dispatch actions: `dispatch(session_id, type, payload \\ nil, meta \\ [])`
102-
- `dispatch_async/4` - Async dispatch returning cancellation: `{:ok, cancel_fn}`
102+
- `dispatch_async/4` - Convenience alias: `dispatch(id, type, payload, [meta | async: true])`
103103
- `subscribe/4` - Subscribe with selector
104104
- `unsubscribe/2` - Remove subscription
105105
- `get_state/1-2` - Get state (client-side, with optional selector)
@@ -266,8 +266,8 @@ The library is organized into several logical groups:
266266
- `type`: binary string (required)
267267
- `payload`: any term (defaults to nil)
268268
- `meta`: keyword list (defaults to [])
269-
- `dispatch_async/4` returns `{:ok, cancel_fn}` where cancel_fn is `(() -> :ok)`
270-
- `handle_async/3` MUST return cancellation callback `(() -> any())`
269+
- `dispatch_async/4` is an alias for `dispatch(id, type, payload, [meta | async: true])`
270+
- `handle_async/3` MUST return cancellation callback `(() -> any())` for internal use
271271

272272
- **LiveView Integration**:
273273
- Use `Phoenix.SessionProcess.LiveView.mount_store/4` for direct subscriptions
@@ -446,13 +446,11 @@ defmodule MyAppWeb.PageController do
446446
:ok = SessionProcess.dispatch(session_id, "counter.increment")
447447
:ok = SessionProcess.dispatch(session_id, "user.set", %{id: 1, name: "Alice"})
448448

449-
# Async dispatch with cancellation
450-
{:ok, cancel_fn} = SessionProcess.dispatch_async(
451-
session_id,
452-
"user.fetch",
453-
123,
454-
async: true
455-
)
449+
# Async dispatch (convenience - automatically adds async: true)
450+
:ok = SessionProcess.dispatch_async(session_id, "user.fetch", 123)
451+
452+
# Equivalent to:
453+
# :ok = SessionProcess.dispatch(session_id, "user.fetch", 123, async: true)
456454

457455
# Get state
458456
state = SessionProcess.get_state(session_id)
@@ -631,11 +629,13 @@ Use `Phoenix.SessionProcess.Error.message/1` for human-readable error messages.
631629
end
632630
```
633631

634-
5. **dispatch_async Returns Cancellation**:
632+
5. **dispatch_async is Convenience Alias**:
635633
```elixir
636-
# Returns {:ok, cancel_fn}
637-
{:ok, cancel_fn} = dispatch_async(session_id, "fetch", nil, async: true)
638-
cancel_fn.() # Call to cancel
634+
# These are equivalent:
635+
:ok = dispatch_async(session_id, "fetch", data)
636+
:ok = dispatch(session_id, "fetch", data, async: true)
637+
638+
# Cancellation is handled internally via handle_async/3 callback in reducer
639639
```
640640

641641
6. **Prefer select_state/2 for Large States**:

README.md

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Add `phoenix_session_process` to your list of dependencies in `mix.exs`:
3737
```elixir
3838
def deps do
3939
[
40-
{:phoenix_session_process, "~> 0.4.0"}
40+
{:phoenix_session_process, "~> 1.0"}
4141
]
4242
end
4343
```
@@ -198,13 +198,11 @@ end
198198
:ok = Phoenix.SessionProcess.dispatch(session_id, "counter.increment")
199199
:ok = Phoenix.SessionProcess.dispatch(session_id, "counter.set", 10)
200200

201-
# Async dispatch returns cancellation function
202-
{:ok, cancel_fn} = Phoenix.SessionProcess.dispatch_async(
203-
session_id,
204-
"counter.increment",
205-
nil,
206-
async: true
207-
)
201+
# Async dispatch (convenience - automatically adds async: true)
202+
:ok = Phoenix.SessionProcess.dispatch_async(session_id, "counter.increment")
203+
204+
# Equivalent to:
205+
# :ok = Phoenix.SessionProcess.dispatch(session_id, "counter.increment", nil, async: true)
208206

209207
# Get state (state is namespaced by reducer)
210208
state = Phoenix.SessionProcess.get_state(session_id)
@@ -438,12 +436,11 @@ The built-in Redux Store API provides state management with reducers defined usi
438436
# Dispatch actions (MUST use binary types)
439437
:ok = Phoenix.SessionProcess.dispatch(session_id, "counter.increment")
440438

441-
# Async dispatch returns cancellation function
442-
{:ok, cancel_fn} = Phoenix.SessionProcess.dispatch_async(
439+
# Async dispatch (convenience alias)
440+
:ok = Phoenix.SessionProcess.dispatch_async(
443441
session_id,
444442
"user.set",
445-
%{id: 123},
446-
async: true
443+
%{id: 123}
447444
)
448445

449446
# Get current state after dispatch

examples/03_async_actions.exs

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ defmodule AsyncReducer do
5050
task =
5151
Task.async(fn ->
5252
# Simulate loading
53-
dispatch.("data.loading")
53+
dispatch.("data.loading", nil, [])
5454
Process.sleep(delay_ms)
5555

5656
# Simulate fetching data
5757
items = ["item1", "item2", "item3"]
58-
dispatch.("data.loaded", items)
58+
dispatch.("data.loaded", items, [])
5959
end)
6060

6161
# Return cancellation callback
@@ -107,19 +107,12 @@ receive do
107107
IO.puts(" • Initial state: loading=#{loading}, items=#{count}")
108108
end
109109

110-
# Dispatch async action
110+
# Dispatch async action (dispatch_async automatically adds async: true)
111111
IO.puts("\n3. Dispatching async fetch (1000ms delay)...")
112112

113-
{:ok, cancel_fn} =
114-
Phoenix.SessionProcess.dispatch_async(
115-
session_id,
116-
"data.fetch",
117-
1000,
118-
async: true
119-
)
113+
:ok = Phoenix.SessionProcess.dispatch_async(session_id, "data.fetch", 1000)
120114

121-
IO.puts(" ✓ Async dispatch started")
122-
IO.puts(" ✓ Received cancellation function")
115+
IO.puts(" ✓ Async dispatch started (fire-and-forget)")
123116

124117
# Receive loading notification
125118
receive do
@@ -142,28 +135,21 @@ end
142135
# Test cancellation
143136
IO.puts("\n4. Testing cancellation...")
144137

145-
{:ok, cancel_fn2} =
146-
Phoenix.SessionProcess.dispatch_async(
147-
session_id,
148-
"data.fetch",
149-
2000,
150-
async: true
151-
)
138+
:ok = Phoenix.SessionProcess.dispatch_async(session_id, "data.fetch", 50)
152139

153-
# Wait a bit
154-
Process.sleep(100)
140+
IO.puts(" • dispatch_async returns :ok (fire-and-forget)")
141+
IO.puts(" • Cancellation is handled internally by handle_async/3 callback")
142+
IO.puts(" • The cancel function is for internal lifecycle management only")
155143

156-
# Cancel the task
157-
IO.puts(" • Calling cancellation function...")
158-
:ok = cancel_fn2.()
144+
# Wait for completion
145+
Process.sleep(100)
159146

160-
# Should not receive completion
161147
receive do
162-
{:state_changed, _} ->
163-
IO.puts(" ⚠️ Received notification (task might have completed before cancel)")
148+
{:state_changed, {loading, count}} ->
149+
IO.puts(" Received notification: loading=#{loading}, items=#{count}")
164150
after
165-
1000 ->
166-
IO.puts(" No notification received (task was cancelled)")
151+
200 ->
152+
IO.puts(" No notification (already processed)")
167153
end
168154

169155
# Get final state

lib/phoenix/session_process.ex

Lines changed: 23 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -636,74 +636,44 @@ defmodule Phoenix.SessionProcess do
636636
end
637637

638638
@doc """
639-
Dispatch an action asynchronously and return a cancellation function.
639+
Dispatch an action asynchronously (convenience alias).
640640
641-
Unlike `dispatch/4` which returns `:ok`, this function returns a cancel callback
642-
that can be used to cancel the pending async action.
641+
This is a convenience function that automatically adds `async: true` to the meta.
642+
It's equivalent to calling `dispatch(session_id, type, payload, [meta | async: true])`.
643+
644+
When `async: true` is in the meta, the action will trigger `handle_async/3` callbacks
645+
in reducers that define them. The `handle_async` callback receives a dispatch function
646+
and must return a cancellation callback for internal lifecycle management.
643647
644648
## Parameters
645649
- `session_id` - Session identifier
646650
- `action_type` - Action type (binary string)
647651
- `payload` - Action payload (any term)
648-
- `meta` - Action metadata (keyword list)
652+
- `meta` - Action metadata (keyword list, `async: true` will be added automatically)
649653
650654
## Returns
651-
- `{:ok, cancel_fn}` - Action dispatched successfully with cancellation function
655+
- `:ok` - Action dispatched successfully
652656
- `{:error, {:session_not_found, session_id}}` - If session doesn't exist
653657
654-
The cancellation function has signature: `cancel_fn.() :: :ok`
655-
Calling it will attempt to cancel the pending action (best-effort).
656-
657658
## Examples
658659
659-
# Async dispatch with cancellation
660-
{:ok, cancel} = SessionProcess.dispatch_async(session_id, "user.reload")
660+
# Equivalent to: dispatch(id, "user.reload", nil, async: true)
661+
:ok = SessionProcess.dispatch_async(session_id, "user.reload")
661662
662-
# Cancel if needed
663-
cancel.()
663+
# With payload - equivalent to: dispatch(id, "fetch_data", data, async: true)
664+
:ok = SessionProcess.dispatch_async(session_id, "fetch_data", %{page: 1})
664665
665-
# With payload
666-
{:ok, cancel} = SessionProcess.dispatch_async(session_id, "fetch_data", %{page: 1})
667-
668-
# Cancel after timeout
669-
Process.send_after(self(), {:cancel, cancel}, 5000)
666+
# With additional meta - equivalent to: dispatch(id, "reload", nil, [priority: :high, async: true])
667+
:ok = SessionProcess.dispatch_async(session_id, "reload", nil, priority: :high)
670668
"""
671669
@spec dispatch_async(binary(), String.t(), term(), keyword()) ::
672-
{:ok, (-> :ok)} | {:error, term()}
670+
:ok | {:error, term()}
673671
def dispatch_async(session_id, action_type, payload \\ nil, meta \\ [])
674672

675673
def dispatch_async(session_id, action_type, payload, meta)
676674
when is_binary(session_id) and is_binary(action_type) and is_list(meta) do
677-
alias Phoenix.SessionProcess.Action
678-
679-
# Convert keyword list to map for Action struct
680-
meta_map = Map.new(meta)
681-
682-
# Generate unique reference for this dispatch
683-
ref = make_ref()
684-
685-
# Add cancel_ref to meta
686-
meta_with_ref = Map.put(meta_map, :cancel_ref, ref)
687-
688-
# Create action from components
689-
action = Action.new(action_type, payload, meta_with_ref)
690-
691-
case ProcessSupervisor.session_process_pid(session_id) do
692-
nil ->
693-
{:error, {:session_not_found, session_id}}
694-
695-
pid ->
696-
# Send async dispatch message
697-
cast(session_id, {:dispatch_action, action})
698-
699-
# Return cancel function
700-
cancel_fn = fn ->
701-
GenServer.cast(pid, {:cancel_action, ref})
702-
:ok
703-
end
704-
705-
{:ok, cancel_fn}
706-
end
675+
# Add async: true to meta and delegate to dispatch/4
676+
dispatch(session_id, action_type, payload, Keyword.put(meta, :async, true))
707677
end
708678

709679
def dispatch_async(_session_id, action_type, _payload, _meta) when not is_binary(action_type) do
@@ -1066,9 +1036,12 @@ defmodule Phoenix.SessionProcess do
10661036
fn -> nil end
10671037
end
10681038
"""
1069-
def handle_async(_action, _dispatch, _state), do: fn -> nil end
10701039

1071-
defoverridable init_state: 0, handle_action: 2, handle_async: 3
1040+
# NOTE: No default implementation for handle_async/3
1041+
# Only export handle_async/3 if explicitly defined by the reducer
1042+
# This ensures function_exported?(module, :handle_async, 3) accurately reflects intent
1043+
1044+
defoverridable init_state: 0, handle_action: 2
10721045
end
10731046
end
10741047

mix.exs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Phoenix.SessionProcess.MixProject do
22
use Mix.Project
33

4-
@version "0.6.0"
4+
@version "1.0.0"
55
@source_url "https://github.com/gsmlg-dev/phoenix_session_process"
66

77
def project do
@@ -111,13 +111,7 @@ defmodule Phoenix.SessionProcess.MixProject do
111111
Utilities: [
112112
Phoenix.SessionProcess.Helpers,
113113
Phoenix.SessionProcess.Telemetry,
114-
Phoenix.SessionProcess.Redux,
115-
Phoenix.SessionProcess.Redux.Selector,
116-
Phoenix.SessionProcess.Redux.Subscription,
117-
Phoenix.SessionProcess.Redux.LiveView,
118-
Phoenix.SessionProcess.MigrationExamples,
119-
Phoenix.SessionProcess.ActivityTracker,
120-
Phoenix.SessionProcess.RateLimiter
114+
Phoenix.SessionProcess.Error
121115
]
122116
],
123117
skip_undefined_reference_warnings_on: ["CHANGELOG.md"]

test/phoenix/session_process/dispatch_test.exs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -100,30 +100,29 @@ defmodule Phoenix.SessionProcess.DispatchTest do
100100
assert state2.test_reducer.count == 10
101101
end
102102

103-
test "dispatch_async returns {:ok, cancel_fn}", %{session_id: session_id} do
103+
test "dispatch_async returns :ok (convenience alias)", %{session_id: session_id} do
104104
result = SessionProcess.dispatch_async(session_id, "increment")
105-
assert {:ok, cancel_fn} = result
106-
assert is_function(cancel_fn, 0)
105+
assert :ok = result
107106

108-
# Wait a bit for async processing
109-
Process.sleep(10)
107+
# Wait for async processing (cast is asynchronous)
108+
Process.sleep(50)
110109

111110
# Verify state changed
112111
state = SessionProcess.get_state(session_id)
113112
assert state.test_reducer.count == 1
114113
end
115114

116-
test "dispatch_async returns cancellation function", %{session_id: session_id} do
117-
# Dispatch async action
118-
{:ok, cancel_fn} = SessionProcess.dispatch_async(session_id, "increment")
115+
test "dispatch_async is equivalent to dispatch with async: true", %{session_id: session_id} do
116+
# These two should be equivalent
117+
:ok = SessionProcess.dispatch_async(session_id, "increment")
118+
:ok = SessionProcess.dispatch(session_id, "increment", nil, async: true)
119119

120-
# Cancellation function can be called (best-effort cancellation)
121-
assert :ok = cancel_fn.()
120+
# Wait for async processing (cast is asynchronous)
121+
Process.sleep(50)
122122

123-
# Note: Due to race conditions, we can't reliably test that the action
124-
# was actually cancelled. The cancel is best-effort - if the action
125-
# has already been processed before the cancel message arrives, it won't be cancelled.
126-
# This test just verifies that the cancel function works without errors.
123+
# Verify both actions were processed
124+
state = SessionProcess.get_state(session_id)
125+
assert state.test_reducer.count == 2
127126
end
128127

129128
test "dispatch to non-existent session returns error", %{} do

0 commit comments

Comments
 (0)