phlex-reactive 0.2.5 → 0.2.6

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: 83d418e64d127a079e74679e4920d85803fedff629e8d8b135c2896a79b5bbdd
4
- data.tar.gz: 16c68638d96348ea94c0fa25d411486e49c0b2dab01f07fe9e67fefba630a537
3
+ metadata.gz: 6db6575334b8d59a054812e6815dc9b1968274a7917c66ec48c353dd95ba477d
4
+ data.tar.gz: 294d2d892c5e8a4b55466e0102b89fe945f7b4f23e1a882b06566c6898520f03
5
5
  SHA512:
6
- metadata.gz: cc16a212daaeb236631702db89fe846dabf936d532e18919c0a371d408f29ce94ae94028eff8cc82e3ff66f1ee25a8acb75f2df29f0b1e2ceb819e49ee3b7217
7
- data.tar.gz: 0b9721189cb032d6a91ae163b15742cb1cb163ef93da4b5ced1d4382811b43da5b5ba3ea25041b5919b20fb58d2dd4d582d8c1ba1345b6d8accf7ac94cdeb978
6
+ metadata.gz: 362f900e12b5d4d0e3240d230b64b7ea1366e9109c1957c266d7695a7f3985b762ce29843c644c12dba6e91ffead0bf6ff7f7e73dc32eac046de79c5c5e3c8cc
7
+ data.tar.gz: 3fc968f66ea7892eba20e050e5be95ad75c878a5518052eaef4006a86b963ac3a3fd1e155d1d4b45f0ea05602d3db1decae1fbb3c75e3bd76cc93bc66a29a673
data/CHANGELOG.md CHANGED
@@ -6,6 +6,35 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.6]
10
+
11
+ ### Added
12
+
13
+ - **Action response control via `Phlex::Reactive::Response`.** An action MAY now
14
+ return a `Response` to govern the actor's HTTP reply, instead of only the
15
+ implicit single re-render. Returning anything else keeps the legacy default
16
+ (re-render the component in place), so existing actions are unaffected.
17
+ - `Response.replace(self)` / `.update(self)` — explicit re-render.
18
+ - `Response.replace(self).flash(:error, msg_or_component)` — surface a
19
+ validation error / notice (the `#1` driver). `.flash` is additive on a
20
+ self-replace, so the component's signed token always refreshes. Flash
21
+ content is supplied explicitly (the render context is off-request — there is
22
+ no Rails `flash`); pass a string or a Phlex component. Target container is
23
+ `Phlex::Reactive.flash_target` (default `flash`).
24
+ - `Response.remove(self)` — drop the element (e.g. a moderation queue). New
25
+ instance helper `Streamable#to_stream_remove` backs it.
26
+ - `Response.redirect(url)` — client-side `Turbo.visit` for when the current
27
+ URL is dead (e.g. a slug rename). Rides a 200 turbo-stream carrying a
28
+ `reactive:visit` custom action (registered in the client), **not** an HTTP
29
+ 3xx (which the client bails on). Pass a `*_url`.
30
+ - `Response.with(*streams)` / `#stream(*more)` — multi-stream (replace self +
31
+ a sibling component).
32
+ - The endpoint guarantees the component's own replace is present (token refresh)
33
+ for non-remove/redirect responses, and never double-prepends when the action
34
+ already included a self-targeted stream.
35
+
36
+ ## [0.2.5]
37
+
9
38
  ### Fixed
10
39
 
11
40
  - **Form submit navigated instead of running the reactive action.** A component
data/README.md CHANGED
@@ -149,7 +149,8 @@ for broadcasting.
149
149
  │ verify signed token (no state trusted)
150
150
  │ rebuild component (record from DB)
151
151
  │ run the whitelisted action
152
- │ re-render → <turbo-stream replace id>
152
+ │ re-render → <turbo-stream replace id> (default; an action
153
+ │ may return a Response — see "Controlling the action's reply")
153
154
  └──────── Turbo morphs it in ◀───────────────────────────────┘
154
155
 
155
156
  ...and for OTHER tabs/users:
@@ -254,7 +255,7 @@ The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
254
255
  | `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
255
256
  | `.broadcast_replace_to(*streamables, model:)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable) |
256
257
  | `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
257
- | `#to_stream_replace` / `#to_stream_update` | Stream the *already-built* instance (used internally after an action) |
258
+ | `#to_stream_replace` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `Response`) |
258
259
 
259
260
  Use in controllers: `render turbo_stream: Counter.replace(counter)`.
260
261
 
@@ -284,6 +285,41 @@ div(**mix(reactive_attrs, id:, class: "card")) { ... }
284
285
  button(**on(:increment), data: { testid: "inc" }) { "+" }
285
286
  ```
286
287
 
288
+ ### `Phlex::Reactive::Response` — controlling the action's reply
289
+
290
+ By default an action re-renders its component in place. **Return** a
291
+ `Phlex::Reactive::Response` to do more (it governs only the actor's HTTP reply —
292
+ cross-tab updates still use `broadcast_*_to(..., exclude: reactive_connection_id)`).
293
+ Returning anything else keeps the default, so existing actions are unaffected.
294
+
295
+ The snippets below alias the constant for brevity (`Response.replace(self)` won't
296
+ resolve to `Phlex::Reactive::Response` inside a namespaced component — fully
297
+ qualify it, or add the alias shown):
298
+
299
+ ```ruby
300
+ Response = Phlex::Reactive::Response # or qualify each call below
301
+
302
+ def rename(title:)
303
+ return Response.replace(self).flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
304
+ Response.replace(self)
305
+ end
306
+
307
+ def approve = (@row.approve!; Response.remove(self)) # drop the element
308
+ def publish = (@article.publish!; Response.redirect(article_url(@article))) # slug changed → Turbo.visit
309
+ def add(item:) = Response.replace(self).stream(Totals.update(@order)) # multi-stream
310
+ ```
311
+
312
+ | Builder | Reply |
313
+ |---|---|
314
+ | `Response.replace(self)` / `.update(self)` | re-render in place (explicit default) |
315
+ | `.flash(level, content, target: …)` | append a flash; `content` is a string or Phlex component (off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
316
+ | `Response.remove(self)` | remove the element (backed by `Streamable#to_stream_remove`) |
317
+ | `Response.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
318
+ | `Response.with(*streams)` / `#stream(*more)` | multi-stream |
319
+
320
+ `.flash`/`.stream` are additive on a self-replace, so the component's signed
321
+ token always refreshes.
322
+
287
323
  ### Configuration (`config/initializers/phlex_reactive.rb`)
288
324
 
289
325
  ```ruby
@@ -36,9 +36,9 @@ module Phlex
36
36
  component = component_class.from_identity(payload)
37
37
  coerced = coerce_params(action_def.params)
38
38
 
39
- run_action(component, action_def, coerced)
39
+ result = run_action(component, action_def, coerced)
40
40
 
41
- render turbo_stream: component.to_stream_replace
41
+ render turbo_stream: response_streams(result, component)
42
42
  rescue Phlex::Reactive::InvalidToken
43
43
  head :bad_request
44
44
  rescue ActiveRecord::RecordNotFound
@@ -69,6 +69,39 @@ module Phlex
69
69
  end
70
70
  end
71
71
 
72
+ # Turn the action's return value into the turbo-stream(s) to render for
73
+ # the actor. A Phlex::Reactive::Response is honored explicitly; any other
74
+ # value (the legacy contract — return value ignored) falls back to the
75
+ # implicit single replace, so existing actions are unaffected.
76
+ def response_streams(result, component)
77
+ return [component.to_stream_replace] unless result.is_a?(Phlex::Reactive::Response)
78
+ return [redirect_stream(result.redirect_url)] if result.redirect?
79
+
80
+ streams = result.streams
81
+ # Guarantee the component's signed identity token is refreshed unless the
82
+ # Response opted out (remove/redirect navigate away — handled above). The
83
+ # client reads the next token from the response body (#extractToken), so
84
+ # the real invariant is "a fresh data-reactive-token-value is present",
85
+ # NOT "some stream targets self". Checking the token directly is correct
86
+ # for replace AND update of self (both re-render the root via
87
+ # render_component, carrying the token), and still adds the fallback
88
+ # replace when a hand-built `with(...)` stream omits it. Idempotent: a
89
+ # Response.replace(self)/update(self) already carries the token, so we
90
+ # don't double the self-render.
91
+ if result.render_self? && streams.none? { |s| s.include?("data-reactive-token-value") }
92
+ streams = [component.to_stream_replace, *streams]
93
+ end
94
+ streams
95
+ end
96
+
97
+ # A 200 turbo-stream carrying a namespaced custom action the client turns
98
+ # into Turbo.visit — NOT an HTTP 3xx, which the client hard-bails on
99
+ # (response.redirected). The matching client handler is registered in
100
+ # reactive_controller.js.
101
+ def redirect_stream(url)
102
+ %(<turbo-stream action="reactive:visit" data-url="#{ERB::Util.html_escape(url)}"></turbo-stream>)
103
+ end
104
+
72
105
  def transaction_wrapper(&block)
73
106
  if defined?(::ActiveRecord::Base)
74
107
  ::ActiveRecord::Base.transaction(&block)
@@ -17,6 +17,25 @@ import { Controller } from "@hotwired/stimulus"
17
17
  // .broadcast_* methods — so a click and a background broadcast converge on
18
18
  // one re-render unit.
19
19
  //
20
+ // Custom turbo-stream action: the server tells the actor to full-navigate
21
+ // (e.g. the record's slug changed and the current URL is now dead). It rides a
22
+ // 200 turbo-stream — NOT an HTTP 3xx — so it never trips the response.redirected
23
+ // bail below (which still correctly catches real auth/CSRF redirects). Registered
24
+ // once on the Turbo global (no @hotwired/turbo import — the gem uses window.Turbo
25
+ // everywhere, and a named import is unreliable under importmap/esbuild).
26
+ export function registerReactiveVisit() {
27
+ const actions = window.Turbo?.StreamActions
28
+ if (!actions || actions["reactive:visit"]) return
29
+ actions["reactive:visit"] = function () {
30
+ const url = this.getAttribute("data-url")
31
+ if (url) window.Turbo.visit(url, { action: "advance" })
32
+ }
33
+ }
34
+ if (typeof window !== "undefined") {
35
+ if (window.Turbo) registerReactiveVisit()
36
+ else document.addEventListener("turbo:load", registerReactiveVisit, { once: true })
37
+ }
38
+
20
39
  // Register this controller eagerly (not lazily) so a click immediately after
21
40
  // page load is never missed. The phlex-reactive engine auto-pins it with
22
41
  // preload: true for importmap apps; see the README for esbuild/webpack.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # An explicit, immutable description of the ACTOR's HTTP response to a
6
+ # reactive action. An action MAY return one; if it returns anything else
7
+ # (the legacy contract — return value ignored), the endpoint falls back to
8
+ # the implicit single component.to_stream_replace.
9
+ #
10
+ # A Response governs ONLY the actor's HTTP reply. Cross-tab updates still go
11
+ # through Streamable's broadcast_*_to(..., exclude: reactive_connection_id).
12
+ #
13
+ # Response.replace(self) # re-render in place (the default, explicit)
14
+ # Response.replace(self).flash(:error, msg) # surface a validation error
15
+ # Response.remove(self) # drop the element (e.g. moderation queue)
16
+ # Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
17
+ # Response.replace(self).stream(Totals.update(@order)) # multi-stream
18
+ class Response
19
+ attr_reader :streams, :redirect_url
20
+
21
+ class << self
22
+ # Re-render the component in place (explicit form of today's default).
23
+ def replace(component) = new(streams: [component.to_stream_replace])
24
+
25
+ # Morph only inner HTML (preserves the root element + its token attr).
26
+ def update(component) = new(streams: [component.to_stream_update])
27
+
28
+ # Remove the component's element from the DOM. Uses the instance
29
+ # to_stream_remove (the component already knows its own #id — no
30
+ # class-builder reconstruction; works for record- and state-backed).
31
+ def remove(component) = new(streams: [component.to_stream_remove], render_self: false)
32
+
33
+ # Client-side full navigation (Turbo.visit). Use when the current URL
34
+ # is dead (slug rename) or the outcome belongs on another page. Pass a
35
+ # *_url (the off-request render context has no request host for *_path).
36
+ def redirect(url) = new(redirect_url: url, render_self: false)
37
+
38
+ # Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
39
+ def with(*strings) = new(streams: strings.flatten)
40
+
41
+ # Build a flash turbo-stream that appends `content` into a host-app
42
+ # container. `content` is a Phlex component instance (rendered through
43
+ # the configured renderer so t()/url_for work) or a ready HTML string —
44
+ # supplied by the caller because the render context is off-request
45
+ # (there is no Rails `flash`).
46
+ def flash_stream(_level, content, target:)
47
+ html = content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
48
+ Phlex::Reactive.flash_builder.append(target, html: html)
49
+ end
50
+ end
51
+
52
+ # render_self: when true (default for replace/update/with), the endpoint
53
+ # GUARANTEES the component's own replace is present so its
54
+ # data-reactive-token-value refreshes (the client extracts the next token
55
+ # from the response HTML). remove/redirect set it false (nothing stays).
56
+ def initialize(streams: [], redirect_url: nil, render_self: true)
57
+ @streams = streams.freeze
58
+ @redirect_url = redirect_url
59
+ @render_self = render_self
60
+ freeze
61
+ end
62
+
63
+ # Append extra turbo-stream strings (a sibling component, a flash).
64
+ # Returns a NEW Response (immutable).
65
+ def stream(*more)
66
+ self.class.new(
67
+ streams: @streams + more.flatten,
68
+ redirect_url: @redirect_url,
69
+ render_self: @render_self
70
+ )
71
+ end
72
+
73
+ # Append a flash turbo-stream into a host-app container (default
74
+ # <div id="flash">, configurable via Phlex::Reactive.flash_target).
75
+ def flash(level, content, target: Phlex::Reactive.flash_target)
76
+ stream(self.class.flash_stream(level, content, target:))
77
+ end
78
+
79
+ def redirect? = !@redirect_url.nil?
80
+ def render_self? = @render_self
81
+ end
82
+ end
83
+ end
@@ -187,6 +187,13 @@ module Phlex
187
187
  def to_stream_update
188
188
  self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
189
189
  end
190
+
191
+ # Render THIS instance as a remove stream. The component already knows its
192
+ # own #id, so no record/class reconstruction is needed (works for record-
193
+ # and state-backed components alike). Used by Response.remove.
194
+ def to_stream_remove
195
+ self.class.turbo_stream_builder.remove(id)
196
+ end
190
197
  end
191
198
  end
192
199
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.2.5"
5
+ VERSION = "0.2.6"
6
6
  end
7
7
  end
@@ -78,6 +78,26 @@ module Phlex
78
78
  @renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
79
79
  end
80
80
 
81
+ # DOM id of the host-app container a Response#flash appends into.
82
+ # Default "flash"; override to match your layout's flash region.
83
+ def flash_target
84
+ @flash_target ||= "flash"
85
+ end
86
+
87
+ attr_writer :flash_target
88
+
89
+ # Render a Phlex component to HTML with a full (off-request) view context.
90
+ def render(component)
91
+ renderer.render(component, layout: false)
92
+ end
93
+
94
+ # A Turbo::Streams::TagBuilder bound to an off-request view context, used
95
+ # to build standalone streams (e.g. a Response flash append) not tied to a
96
+ # specific component's id.
97
+ def flash_builder
98
+ ::Turbo::Streams::TagBuilder.new(renderer.new.view_context)
99
+ end
100
+
81
101
  def base_controller_name
82
102
  @base_controller_name ||= "ActionController::Base"
83
103
  end
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.2.5
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -129,6 +129,7 @@ files:
129
129
  - lib/phlex/reactive.rb
130
130
  - lib/phlex/reactive/component.rb
131
131
  - lib/phlex/reactive/engine.rb
132
+ - lib/phlex/reactive/response.rb
132
133
  - lib/phlex/reactive/streamable.rb
133
134
  - lib/phlex/reactive/version.rb
134
135
  homepage: https://github.com/mhenrixon/phlex-reactive