phlex-reactive 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 913a7629041138c8daf5a4538e694a557f77aeffd137fe7320ce1654efff6aae
4
- data.tar.gz: ea6dc93c905242537e00b05d651b98cbea3933a9188e0ae0e10594c87b997504
3
+ metadata.gz: 25876f4b6f97e3cd517a4b5a575e3156ac7cadd4025e9f5cdf5f10b0bb001346
4
+ data.tar.gz: f5ea2f6cee8e7b17f66725549e98f2bc9d1cc3c29cf90acfb2d1b88c1176e2ea
5
5
  SHA512:
6
- metadata.gz: 49f1fbd3b2caa40817f91968e4c39658e8fb02a6ab11850ef8a5a5ba7c5f901b4910e7196642b670c61b74060242f550740937b60c71a8fee3af31c5a080f270
7
- data.tar.gz: e1fcfbbc3f8c2cb3e22b0929830f7191f59d440b3b1b023a14222547c74fef509face1fdfed4d3a19b3ef13df33f7d4584a1545cc67970cce9ad8de637daf6bf
6
+ metadata.gz: ec2bc617e5a888e268e6e746f85a225e92d88145114687be55b825c9659b2d9ef75b35d25d67d6dc950070acadf3f387b2d2608108f4b91d2a0dfcd8982495e1
7
+ data.tar.gz: c2249b5dd3783ec5f9d4881290e15030ca4de9e6e4e1af238f239feb55c63df76a5c5f4d1098e9bb1022547766752a03825b63d7dac60830dcc6cd5526d929ca
data/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0]
10
+
11
+ ### Added
12
+
13
+ - **Actor-echo suppression.** `broadcast_*_to` now accepts `exclude:` (and
14
+ `visible_to:`), forwarded to the stream transport. Pass
15
+ `exclude: reactive_connection_id` from an action so the actor doesn't receive
16
+ the echo of its own broadcast — making `append`/`prepend` and optimistic UI
17
+ safe. The client sends its SSE connection id as `X-Pgbus-Connection`; the
18
+ endpoint exposes it via `Phlex::Reactive.current_connection_id` /
19
+ `reactive_connection_id`. Honored by pgbus; ignored (harmless) on Action Cable.
20
+
21
+ ### Requires
22
+
23
+ - **pgbus >= 0.9.4** when using `exclude:`/`visible_to:` — it ships the
24
+ `exclude:`/`visible_to:`/`event:` forwarding through Turbo's broadcast
25
+ helpers. phlex-reactive still works on Action Cable without pgbus; the
26
+ `exclude:` argument is simply ignored there.
27
+
28
+ ## [0.1.0]
29
+
9
30
  ### Added
10
31
 
11
32
  - `Phlex::Reactive::Streamable` — class methods (`replace`, `update`, `append`,
@@ -22,5 +43,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
22
43
  synchronous token threading, auto field collection; no per-feature controllers.
23
44
  - Rails engine — mounts the action endpoint, registers and auto-pins the client
24
45
  runtime for importmap apps.
46
+ - **Generators.** `rails g phlex:reactive:install` registers the `reactive`
47
+ Stimulus controller (eagerly) and writes a config initializer.
48
+ `rails g phlex:reactive:component Name [actions] [--record name | --state vars]`
49
+ scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
50
+ state-backed by default or record-backed with `--record`.
25
51
 
26
- [Unreleased]: https://github.com/mhenrixon/phlex-reactive/commits/main
52
+ [Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.0...HEAD
53
+ [0.2.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.1.0...v0.2.0
54
+ [0.1.0]: https://github.com/mhenrixon/phlex-reactive/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # phlex-reactive
2
2
 
3
+ [![CI](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml/badge.svg)](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/phlex-reactive)](https://rubygems.org/gems/phlex-reactive)
5
+ [![Docs](https://img.shields.io/badge/docs-mhenrixon.github.io-blue)](https://mhenrixon.github.io/phlex-reactive)
6
+
3
7
  **Reactive [Phlex](https://www.phlex.fun) components for Rails — Livewire-style
4
8
  actions and live cross-tab updates, without writing Stimulus controllers or
5
9
  hand-picking Turbo Stream targets.**
6
10
 
11
+ 📖 **[Full documentation](https://mhenrixon.github.io/phlex-reactive)**
12
+
7
13
  ```ruby
8
14
  class Counter < ApplicationComponent
9
15
  include Phlex::Reactive::Streamable
@@ -75,9 +81,19 @@ gem "phlex-reactive"
75
81
  bundle install
76
82
  ```
77
83
 
78
- That's it for **importmap** apps — the engine mounts the action endpoint at
79
- `/reactive/actions` and auto-pins (and preloads) the client runtime. Register
80
- the controller eagerly so a click immediately after load is never missed:
84
+ Then run the installerit registers the client controller and writes a config
85
+ initializer:
86
+
87
+ ```bash
88
+ bin/rails generate phlex:reactive:install
89
+ ```
90
+
91
+ That's all for **importmap** apps: the engine mounts the action endpoint at
92
+ `/reactive/actions` and auto-pins (and preloads) the client runtime, and the
93
+ installer adds the eager registration below to your Stimulus entrypoint.
94
+
95
+ <details>
96
+ <summary>What the installer wires (or do it by hand)</summary>
81
97
 
82
98
  ```js
83
99
  // app/javascript/controllers/index.js
@@ -86,6 +102,21 @@ import ReactiveController from "phlex/reactive/reactive_controller"
86
102
  application.register("reactive", ReactiveController)
87
103
  ```
88
104
 
105
+ Register eagerly (not lazily) so a click immediately after load is never missed.
106
+ </details>
107
+
108
+ ### Scaffold a component
109
+
110
+ ```bash
111
+ # state-backed (record-less)
112
+ bin/rails generate phlex:reactive:component Counter increment decrement
113
+
114
+ # record-backed (signed GlobalID identity)
115
+ bin/rails generate phlex:reactive:component Todos::Item toggle rename --record todo
116
+ ```
117
+
118
+ Generates the component (and an RSpec spec if your app uses RSpec).
119
+
89
120
  <details>
90
121
  <summary>esbuild / webpack / bun</summary>
91
122
 
@@ -234,6 +265,19 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
234
265
  Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
235
266
  in the schema is dropped before reaching your method.
236
267
 
268
+ **Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
269
+ a hash that includes a `data:` key. Spreading them *and* passing another `data:`
270
+ (or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge:
271
+
272
+ ```ruby
273
+ # ✅ merges cleanly (data-action survives, your data-testid/class are added)
274
+ button(**mix(on(:increment), class: "btn", data: { testid: "inc" })) { "+" }
275
+ div(**mix(reactive_attrs, id:, class: "card")) { ... }
276
+
277
+ # ❌ the extra data: overwrites on()'s data:, so the action never binds
278
+ button(**on(:increment), data: { testid: "inc" }) { "+" }
279
+ ```
280
+
237
281
  ### Configuration (`config/initializers/phlex_reactive.rb`)
238
282
 
239
283
  ```ruby
@@ -52,12 +52,19 @@ module Phlex
52
52
  # Run the action inside a transaction so transactional broadcasts (pgbus
53
53
  # broadcasts_to ... durable:) defer to after_commit and never fire for a
54
54
  # rolled-back change. Override to add per-request instrumentation.
55
+ #
56
+ # The actor's SSE connection id (sent as X-Pgbus-Connection) is exposed
57
+ # for the duration of the action via Phlex::Reactive.current_connection_id,
58
+ # so a broadcast in the action can pass exclude: reactive_connection_id
59
+ # and skip the actor's own echo.
55
60
  def run_action(component, action_def, coerced)
56
- transaction_wrapper do
57
- if coerced.any?
58
- component.public_send(action_def.name, **coerced)
59
- else
60
- component.public_send(action_def.name)
61
+ Phlex::Reactive.with_connection_id(request.headers["X-Pgbus-Connection"]) do
62
+ transaction_wrapper do
63
+ if coerced.any?
64
+ component.public_send(action_def.name, **coerced)
65
+ else
66
+ component.public_send(action_def.name)
67
+ end
61
68
  end
62
69
  end
63
70
  end
@@ -97,7 +104,7 @@ module Phlex
97
104
  def coerce(value, type)
98
105
  case type
99
106
  when :integer then value.to_i
100
- when :float then value.to_f
107
+ when :float then value.to_f
101
108
  when :boolean then ActiveModel::Type::Boolean.new.cast(value)
102
109
  else value.to_s
103
110
  end
@@ -107,7 +114,7 @@ module Phlex
107
114
  # already gates this; defense in depth against constant injection.
108
115
  def resolve_component(name)
109
116
  klass = name.to_s.safe_constantize
110
- unless klass && klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
117
+ unless klass&.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
111
118
  raise Phlex::Reactive::InvalidToken
112
119
  end
113
120
 
@@ -60,13 +60,20 @@ export default class extends Controller {
60
60
  this.element.setAttribute("aria-busy", "true")
61
61
 
62
62
  try {
63
+ const headers = {
64
+ "Content-Type": "application/json",
65
+ Accept: "text/vnd.turbo-stream.html",
66
+ "X-CSRF-Token": this.#csrfToken(),
67
+ }
68
+ // Send the pgbus SSE connection id (if subscribed) so the server can
69
+ // exclude this connection from its own broadcast echo — the actor
70
+ // already gets the action's HTTP response. Harmless without pgbus.
71
+ const connectionId = this.#connectionId()
72
+ if (connectionId) headers["X-Pgbus-Connection"] = connectionId
73
+
63
74
  const response = await fetch(this.#actionPath(), {
64
75
  method: "POST",
65
- headers: {
66
- "Content-Type": "application/json",
67
- Accept: "text/vnd.turbo-stream.html",
68
- "X-CSRF-Token": this.#csrfToken(),
69
- },
76
+ headers,
70
77
  body,
71
78
  credentials: "same-origin",
72
79
  })
@@ -146,4 +153,17 @@ export default class extends Controller {
146
153
  #csrfToken() {
147
154
  return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
148
155
  }
156
+
157
+ // The pgbus SSE connection id, if the page is subscribed to a stream. pgbus
158
+ // reflects it onto the <pgbus-stream-source connection-id="…"> element (and
159
+ // apps may mirror it to <meta name="pgbus-connection-id">). Returns null
160
+ // when not present (e.g. no pgbus, or no active subscription) — the header
161
+ // is then simply omitted.
162
+ #connectionId() {
163
+ return (
164
+ document.querySelector("pgbus-stream-source[connection-id]")?.getAttribute("connection-id") ||
165
+ document.querySelector('meta[name="pgbus-connection-id"]')?.content ||
166
+ null
167
+ )
168
+ }
149
169
  }
@@ -0,0 +1,14 @@
1
+ Description:
2
+ Scaffolds a reactive Phlex component (and an RSpec spec) with the given
3
+ actions. State-backed by default; pass --record for a record-backed
4
+ component whose identity is a signed GlobalID.
5
+
6
+ Examples:
7
+ bin/rails generate phlex:reactive:component Counter increment decrement
8
+ State-backed component with two actions, signed state (default :value).
9
+
10
+ bin/rails generate phlex:reactive:component Counter increment --state count
11
+ State-backed, signing @count.
12
+
13
+ bin/rails generate phlex:reactive:component Todos::Item toggle rename --record todo
14
+ Record-backed component; identity = todo.to_gid; #id = dom_id(@todo).
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+
5
+ module Phlex
6
+ module Reactive
7
+ module Generators
8
+ # `rails g phlex:reactive:component Counter [actions] [options]`
9
+ #
10
+ # Examples:
11
+ # rails g phlex:reactive:component Counter increment decrement
12
+ # → state-backed component with two actions
13
+ #
14
+ # rails g phlex:reactive:component Todos::Item toggle rename --record=todo
15
+ # → record-backed component (signed GlobalID) with two actions
16
+ #
17
+ # Generates the component under app/components and a matching spec under
18
+ # spec/components (or test/components for Minitest apps).
19
+ class ComponentGenerator < ::Rails::Generators::NamedBase
20
+ source_root File.expand_path("templates", __dir__)
21
+
22
+ argument :actions, type: :array, default: [], banner: "action action"
23
+
24
+ class_option :record, type: :string, default: nil,
25
+ desc: "Record-backed component: the init kwarg / GlobalID record name (e.g. --record=todo)"
26
+ class_option :state, type: :array, default: nil,
27
+ desc: "State-backed: instance vars to sign (e.g. --state=count open). Default for record-less."
28
+
29
+ def create_component
30
+ @component_path = File.join("app/components", class_path, "#{file_name}.rb")
31
+ template "component.rb.erb", File.join(destination_root, @component_path)
32
+ end
33
+
34
+ def create_spec
35
+ return unless defined_rspec?
36
+
37
+ @spec_path = File.join("spec/components", class_path, "#{file_name}_spec.rb")
38
+ template "component_spec.rb.erb", File.join(destination_root, @spec_path)
39
+ end
40
+
41
+ private
42
+
43
+ def record_name
44
+ options[:record]&.to_sym
45
+ end
46
+
47
+ def record_backed?
48
+ record_name.present?
49
+ end
50
+
51
+ def state_keys
52
+ options[:state] || (record_backed? ? [] : %w[value])
53
+ end
54
+
55
+ def action_names
56
+ actions.presence || %w[example_action]
57
+ end
58
+
59
+ def defined_rspec?
60
+ File.directory?(File.join(destination_root, "spec"))
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < ApplicationComponent
4
+ include Phlex::Reactive::Streamable
5
+ include Phlex::Reactive::Component
6
+
7
+ <% if record_backed? -%>
8
+ reactive_record :<%= record_name %>
9
+ <% action_names.each do |a| -%>
10
+ action :<%= a %>
11
+ <% end -%>
12
+
13
+ def initialize(<%= record_name %>:)
14
+ @<%= record_name %> = <%= record_name %>
15
+ end
16
+
17
+ def id = dom_id(@<%= record_name %>)
18
+ <% action_names.each do |a| -%>
19
+
20
+ def <%= a %>
21
+ # authorize! @<%= record_name %>, :update? # authorize mutating actions
22
+ # @<%= record_name %>.update!(...)
23
+ end
24
+ <% end -%>
25
+
26
+ def view_template
27
+ div(id:, **reactive_attrs) do
28
+ <% action_names.each do |a| -%>
29
+ button(**on(:<%= a %>)) { "<%= a.to_s.humanize %>" }
30
+ <% end -%>
31
+ end
32
+ end
33
+ <% else -%>
34
+ reactive_state <%= state_keys.map { |k| ":#{k}" }.join(", ") %>
35
+ <% action_names.each do |a| -%>
36
+ action :<%= a %>
37
+ <% end -%>
38
+
39
+ def initialize(<%= state_keys.map { |k| "#{k}: nil" }.join(", ") %>)
40
+ <% state_keys.each do |k| -%>
41
+ @<%= k %> = <%= k %>
42
+ <% end -%>
43
+ end
44
+
45
+ def id = "<%= file_name.dasherize %>"
46
+ <% action_names.each do |a| -%>
47
+
48
+ def <%= a %>
49
+ # mutate @<%= state_keys.first %> here
50
+ end
51
+ <% end -%>
52
+
53
+ def view_template
54
+ div(id:, **reactive_attrs) do
55
+ <% state_keys.each do |k| -%>
56
+ span(data: { <%= file_name %>_<%= k %>: true }) { @<%= k %>.to_s }
57
+ <% end -%>
58
+ <% action_names.each do |a| -%>
59
+ button(**on(:<%= a %>)) { "<%= a.to_s.humanize %>" }
60
+ <% end -%>
61
+ end
62
+ end
63
+ <% end -%>
64
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ RSpec.describe <%= class_name %> do
6
+ describe "reactive declarations" do
7
+ <% action_names.each do |a| -%>
8
+ it "declares the :<%= a %> action" do
9
+ expect(described_class.reactive_action?(:<%= a %>)).to be(true)
10
+ end
11
+ <% end -%>
12
+ end
13
+ <% if record_backed? -%>
14
+
15
+ describe "identity" do
16
+ it "signs the record's GlobalID into a verifiable token" do
17
+ # record = <%= record_name %>_fixture_or_factory
18
+ # token = described_class.new(<%= record_name %>: record).send(:reactive_token)
19
+ # expect(Phlex::Reactive.verify(token)["gid"]).to eq(record.to_gid.to_s)
20
+ skip "provide a <%= record_name %> record to assert identity round-trip"
21
+ end
22
+ end
23
+ <% else -%>
24
+
25
+ describe "identity" do
26
+ it "signs state into a verifiable token and rebuilds it" do
27
+ component = described_class.new(<%= state_keys.map { |k| "#{k}: #{k == state_keys.first ? "1" : "nil"}" }.join(", ") %>)
28
+ payload = Phlex::Reactive.verify(component.send(:reactive_token))
29
+ expect(payload["c"]).to eq("<%= class_name %>")
30
+ expect(described_class.from_identity(payload)).to be_a(described_class)
31
+ end
32
+ end
33
+ <% end -%>
34
+ end
@@ -0,0 +1,12 @@
1
+ Description:
2
+ Sets up phlex-reactive in your app: registers the generic `reactive`
3
+ Stimulus controller (eagerly) and writes a config initializer.
4
+
5
+ The action endpoint (POST /reactive/actions) and the client auto-pin are
6
+ provided by the engine, so this only wires the host-app bits.
7
+
8
+ Example:
9
+ bin/rails generate phlex:reactive:install
10
+
11
+ Registers the controller in app/javascript/controllers/index.js and
12
+ creates config/initializers/phlex_reactive.rb.
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Phlex
6
+ module Reactive
7
+ module Generators
8
+ # `rails g phlex:reactive:install`
9
+ #
10
+ # One-command setup for phlex-reactive:
11
+ # * registers the generic `reactive` Stimulus controller (eagerly)
12
+ # * writes a config initializer with the common options commented out
13
+ #
14
+ # The action endpoint and client auto-pin are provided by the engine, so
15
+ # this generator only wires the bits that live in the host app.
16
+ class InstallGenerator < ::Rails::Generators::Base
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ IMPORT_LINE = 'import ReactiveController from "phlex/reactive/reactive_controller"'
20
+ REGISTER_LINE = 'application.register("reactive", ReactiveController)'
21
+
22
+ def create_initializer
23
+ template "phlex_reactive.rb.erb", "config/initializers/phlex_reactive.rb"
24
+ end
25
+
26
+ # Register the controller eagerly in the Stimulus entrypoint. Eager (not
27
+ # lazy) so a click immediately after page load is never missed.
28
+ def register_stimulus_controller
29
+ index = stimulus_index_path
30
+
31
+ unless index
32
+ say_status :skip, "no Stimulus controllers entrypoint found — register manually:\n" \
33
+ " #{IMPORT_LINE}\n #{REGISTER_LINE}", :yellow
34
+ return
35
+ end
36
+
37
+ if File.read(index).include?(REGISTER_LINE)
38
+ say_status :identical, relative(index), :blue
39
+ return
40
+ end
41
+
42
+ inject_into_file index, after: %r{import \{ application \} from ["']controllers/application["']\n} do
43
+ "#{IMPORT_LINE}\n#{REGISTER_LINE}\n"
44
+ end
45
+
46
+ # Fallback: if the standard import line wasn't found, append both lines.
47
+ unless File.read(index).include?(REGISTER_LINE)
48
+ append_to_file index, "\n#{IMPORT_LINE}\n#{REGISTER_LINE}\n"
49
+ end
50
+ end
51
+
52
+ def show_post_install
53
+ say_status :info, "phlex-reactive installed. Include the mixins in a component:", :green
54
+ say <<~MSG
55
+
56
+ class Counter < ApplicationComponent
57
+ include Phlex::Reactive::Streamable
58
+ include Phlex::Reactive::Component
59
+ # ... reactive_state / reactive_record, action :name, on(:name) ...
60
+ end
61
+
62
+ Scaffold one with: rails g phlex:reactive:component Counter
63
+ MSG
64
+ end
65
+
66
+ private
67
+
68
+ def stimulus_index_path
69
+ %w[
70
+ app/javascript/controllers/index.js
71
+ app/javascript/controllers/application.js
72
+ ].map { |p| File.join(destination_root, p) }.find { |p| File.exist?(p) }
73
+ end
74
+
75
+ def relative(path)
76
+ path.sub("#{destination_root}/", "")
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # phlex-reactive configuration. The engine mounts POST /reactive/actions and
4
+ # auto-pins the client runtime; these options are the ones you typically tune.
5
+
6
+ # Inherit auth/CSRF/Current on the action endpoint (recommended for real apps).
7
+ # Ensure the endpoint isn't force-redirected to a login page for logged-out
8
+ # users if you have public reactive components.
9
+ # Phlex::Reactive.base_controller_name = "ApplicationController"
10
+
11
+ # Render components with your app's view context (helpers, Current, etc.).
12
+ # Phlex::Reactive.renderer = ApplicationController
13
+
14
+ # Render your authorization library's error as 403 from a reactive action.
15
+ # Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
16
+ # Phlex::Reactive.authorization_errors = [ActionPolicy::Unauthorized]
17
+
18
+ # Sign identity tokens with a dedicated key instead of secret_key_base.
19
+ # Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"])
20
+
21
+ # Change the action endpoint path (default "/reactive/actions"). If you change
22
+ # it, expose it to the client with:
23
+ # <meta name="phlex-reactive-action-path" content="<%%= Phlex::Reactive.action_path %>">
24
+ # Phlex::Reactive.action_path = "/_r/actions"
@@ -116,6 +116,21 @@ module Phlex
116
116
  end
117
117
  end
118
118
 
119
+ # The acting client's SSE connection id during the current action (nil
120
+ # outside an action, or when the client isn't subscribed to a stream).
121
+ # Pass it as `exclude:` when broadcasting from an action so the actor
122
+ # doesn't receive the echo of its own change — it already gets the
123
+ # action's HTTP response:
124
+ #
125
+ # def send_message(body:)
126
+ # msg = ChatMessage.create!(room: @room, body:)
127
+ # ChatMessage::Item.broadcast_append_to("chat", @room,
128
+ # target: "messages", model: msg, exclude: reactive_connection_id)
129
+ # end
130
+ def reactive_connection_id
131
+ Phlex::Reactive.current_connection_id
132
+ end
133
+
119
134
  # Root-element attributes: marks the element reactive and carries the
120
135
  # signed identity token. Spread onto the root:
121
136
  # div(id:, **reactive_attrs) { ... }
@@ -154,12 +169,12 @@ module Phlex
154
169
  payload =
155
170
  if self.class.reactive_record_key
156
171
  record = instance_variable_get(:"@#{self.class.reactive_record_key}")
157
- { "c" => self.class.name, "gid" => record.to_gid.to_s }
172
+ {"c" => self.class.name, "gid" => record.to_gid.to_s}
158
173
  else
159
174
  state = self.class.reactive_state_keys.to_h do |k|
160
175
  [k.to_s, instance_variable_get(:"@#{k}").as_json]
161
176
  end
162
- { "c" => self.class.name, "s" => state }
177
+ {"c" => self.class.name, "s" => state}
163
178
  end
164
179
 
165
180
  Phlex::Reactive.sign(payload)
@@ -35,11 +35,18 @@ module Phlex
35
35
  end
36
36
 
37
37
  def component_args(model, options)
38
- { model_param_name => model }.merge(options)
38
+ {model_param_name => model}.merge(options)
39
39
  end
40
40
 
41
+ # Turbo::Streams::TagBuilder needs a real VIEW CONTEXT (it calls
42
+ # `.formats` on it), not a controller class. Build one off-request from
43
+ # the configured renderer controller. Memoized per class.
41
44
  def turbo_stream_builder
42
- ::Turbo::Streams::TagBuilder.new(renderer)
45
+ ::Turbo::Streams::TagBuilder.new(turbo_view_context)
46
+ end
47
+
48
+ def turbo_view_context
49
+ renderer.new.view_context
43
50
  end
44
51
 
45
52
  # Render a component to HTML with a full Rails view context. Routing
@@ -83,37 +90,49 @@ module Phlex
83
90
  # broadcaster builds the key itself, and double-keying can trip the
84
91
  # transport's separator guard.
85
92
 
86
- def broadcast_replace_to(*streamables, model: nil, **options)
93
+ # `exclude:` / `visible_to:` are TRANSPORT options forwarded to the
94
+ # stream (pgbus), not component init args. `exclude:` is the actor's
95
+ # connection id — pass it to suppress the actor's own echo (the actor
96
+ # already got the action's HTTP response). With Action Cable these are
97
+ # ignored; with pgbus they reach the dispatcher. See docs/broadcasting.
98
+ def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
87
99
  component = build(model, options)
88
100
  ::Turbo::StreamsChannel.broadcast_replace_to(
89
- *streamables, target: component.id, html: render_component(component)
101
+ *streamables, target: component.id, html: render_component(component),
102
+ **broadcast_transport_opts(exclude:, visible_to:)
90
103
  )
91
104
  end
92
105
 
93
- def broadcast_update_to(*streamables, model: nil, **options)
106
+ def broadcast_update_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
94
107
  component = build(model, options)
95
108
  ::Turbo::StreamsChannel.broadcast_update_to(
96
- *streamables, target: component.id, html: render_component(component)
109
+ *streamables, target: component.id, html: render_component(component),
110
+ **broadcast_transport_opts(exclude:, visible_to:)
97
111
  )
98
112
  end
99
113
 
100
- def broadcast_append_to(*streamables, target:, model: nil, **options)
114
+ def broadcast_append_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
101
115
  component = build(model, options)
102
116
  ::Turbo::StreamsChannel.broadcast_append_to(
103
- *streamables, target:, html: render_component(component)
117
+ *streamables, target:, html: render_component(component),
118
+ **broadcast_transport_opts(exclude:, visible_to:)
104
119
  )
105
120
  end
106
121
 
107
- def broadcast_prepend_to(*streamables, target:, model: nil, **options)
122
+ def broadcast_prepend_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
108
123
  component = build(model, options)
109
124
  ::Turbo::StreamsChannel.broadcast_prepend_to(
110
- *streamables, target:, html: render_component(component)
125
+ *streamables, target:, html: render_component(component),
126
+ **broadcast_transport_opts(exclude:, visible_to:)
111
127
  )
112
128
  end
113
129
 
114
- def broadcast_remove_to(*streamables, model: nil, **options)
130
+ def broadcast_remove_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
115
131
  component = build(model, options)
116
- ::Turbo::StreamsChannel.broadcast_remove_to(*streamables, target: component.id)
132
+ ::Turbo::StreamsChannel.broadcast_remove_to(
133
+ *streamables, target: component.id,
134
+ **broadcast_transport_opts(exclude:, visible_to:)
135
+ )
117
136
  end
118
137
 
119
138
  private
@@ -122,6 +141,15 @@ module Phlex
122
141
  new(**(model ? component_args(model, options) : options))
123
142
  end
124
143
 
144
+ # Only include transport opts that were actually given, so on Action
145
+ # Cable (which doesn't accept them) the common no-opts call is unchanged.
146
+ def broadcast_transport_opts(exclude:, visible_to:)
147
+ opts = {}
148
+ opts[:exclude] = exclude unless exclude.nil?
149
+ opts[:visible_to] = visible_to unless visible_to.nil?
150
+ opts
151
+ end
152
+
125
153
  def renderer
126
154
  Phlex::Reactive.renderer
127
155
  end
@@ -133,6 +161,15 @@ module Phlex
133
161
  raise NotImplementedError, "#{self.class} must implement #id for Turbo Stream targeting"
134
162
  end
135
163
 
164
+ # Render-context-free dom_id, safe to use inside `#id`. The streamable
165
+ # machinery calls `#id` BEFORE rendering, so Phlex's render-time `dom_id`
166
+ # helper would raise HelpersCalledBeforeRenderError. This delegates to
167
+ # ActionView::RecordIdentifier, which works anywhere — so
168
+ # `def id = dom_id(@todo)` is safe.
169
+ def dom_id(record, prefix = nil)
170
+ ::ActionView::RecordIdentifier.dom_id(record, prefix)
171
+ end
172
+
136
173
  # Render THIS already-built instance as a replace stream (used by the
137
174
  # reactive action endpoint after an action mutated state).
138
175
  def to_stream_replace
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -90,6 +90,23 @@ module Phlex
90
90
  verifier.generate(payload, purpose: IDENTITY_PURPOSE)
91
91
  end
92
92
 
93
+ # The acting client's SSE connection id during an action, or nil. Set by
94
+ # the ActionsController from the X-Pgbus-Connection header. A component
95
+ # action passes `exclude: Phlex::Reactive.current_connection_id` (or the
96
+ # `reactive_connection_id` helper) to suppress the actor's own broadcast
97
+ # echo.
98
+ def current_connection_id
99
+ Thread.current[:phlex_reactive_connection_id]
100
+ end
101
+
102
+ def with_connection_id(connection_id)
103
+ previous = Thread.current[:phlex_reactive_connection_id]
104
+ Thread.current[:phlex_reactive_connection_id] = connection_id.presence
105
+ yield
106
+ ensure
107
+ Thread.current[:phlex_reactive_connection_id] = previous
108
+ end
109
+
93
110
  private
94
111
 
95
112
  def default_verifier
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -118,6 +118,13 @@ files:
118
118
  - README.md
119
119
  - app/controllers/phlex/reactive/actions_controller.rb
120
120
  - app/javascript/phlex/reactive/reactive_controller.js
121
+ - lib/generators/phlex/reactive/component/USAGE
122
+ - lib/generators/phlex/reactive/component/component_generator.rb
123
+ - lib/generators/phlex/reactive/component/templates/component.rb.erb
124
+ - lib/generators/phlex/reactive/component/templates/component_spec.rb.erb
125
+ - lib/generators/phlex/reactive/install/USAGE
126
+ - lib/generators/phlex/reactive/install/install_generator.rb
127
+ - lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb
121
128
  - lib/phlex-reactive.rb
122
129
  - lib/phlex/reactive.rb
123
130
  - lib/phlex/reactive/component.rb
@@ -146,7 +153,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
146
153
  - !ruby/object:Gem::Version
147
154
  version: '0'
148
155
  requirements: []
149
- rubygems_version: 4.0.9
156
+ rubygems_version: 3.6.9
150
157
  specification_version: 4
151
158
  summary: Reactive Phlex components for Rails — Livewire-style actions and live cross-tab
152
159
  updates, no Stimulus boilerplate.