Class: Phlex::Reactive::Response

Inherits:
Object
  • Object
show all
Defined in:
lib/phlex/reactive/response.rb

Overview

An explicit, immutable description of the ACTOR's HTTP response to a reactive action. An action MAY return one; if it returns anything else (the legacy contract — return value ignored), the endpoint falls back to the implicit single component.to_stream_replace.

A Response governs ONLY the actor's HTTP reply. Cross-tab updates still go through Streamable's broadcast_*_to(..., exclude: reactive_connection_id).

Response.replace(self)                          # re-render in place (the default, explicit)
Response.replace(self).flash(:error, msg)       # surface a validation error
Response.replace(self).also_update("heading", html: @record.name)  # + a companion element
Response.remove(self)                           # drop the element (e.g. moderation queue)
Response.redirect(article_url(@article))        # slug changed -> Turbo.visit the new URL
Response.replace(self).stream(Totals.update(@order))  # multi-stream

Constant Summary collapse

NO_SEGMENTS =

render_self: when true (default for replace/update/with), the endpoint GUARANTEES the component's own replace is present so its data-reactive-token-value refreshes (the client extracts the next token from the response HTML). remove/redirect set it false (nothing stays).

token_component: set by .streams (issue #30) — a partial update that opts OUT of the full-self replace but still needs the token refreshed. The endpoint appends this component's tiny to_stream_token instead, so the token rolls forward without re-rendering (and clobbering) the children.

subject_component: the component a self-targeting builder (replace/morph/ update) re-renders (issue #97). Distinct from token_component so it does NOT trip refresh_token? — it exists only to default #js's target to the bound component's id, so reply.morph.js(js.focus("@root")) scopes to the morphed root without the caller repeating the id. No deferred segments — the shared default so the common (non-defer) reply never allocates an empty array per Response.

[].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil, subject_component: nil, deferred_segments: NO_SEGMENTS) ⇒ Response

Returns a new instance of Response.



319
320
321
322
323
324
325
326
327
328
# File 'lib/phlex/reactive/response.rb', line 319

def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil,
               subject_component: nil, deferred_segments: NO_SEGMENTS)
  @streams = streams.freeze
  @redirect_url = redirect_url
  @render_self = render_self
  @token_component = token_component
  @subject_component = subject_component
  @deferred_segments = deferred_segments.freeze
  freeze
end

Instance Attribute Details

#deferred_segmentsObject (readonly)

The recorded reply.defer segments (issue #165), in call order. The Response only RECORDS them; the endpoint (via the Defer module) builds the placeholder + directive streams AFTER the action's transaction committed and picks the delivery lane.



334
335
336
# File 'lib/phlex/reactive/response.rb', line 334

def deferred_segments
  @deferred_segments
end

#redirect_urlObject (readonly)

Returns the value of attribute redirect_url.



20
21
22
# File 'lib/phlex/reactive/response.rb', line 20

def redirect_url
  @redirect_url
end

#streamsObject (readonly)

Returns the value of attribute streams.



20
21
22
# File 'lib/phlex/reactive/response.rb', line 20

def streams
  @streams
end

#subject_componentObject (readonly)

Returns the value of attribute subject_component.



20
21
22
# File 'lib/phlex/reactive/response.rb', line 20

def subject_component
  @subject_component
end

#token_componentObject (readonly)

Returns the value of attribute token_component.



20
21
22
# File 'lib/phlex/reactive/response.rb', line 20

def token_component
  @token_component
end

Class Method Details

.collection_append(component, name, model) ⇒ Object

--- Reactive collections (issue #35) --- Add/remove a row in a declared reactive_collection, emitting the row stream + the count companion update + the empty-state toggle as ONE Response. component is the bound CONTAINER (it carries the declaration and the size resolver); model is the row's record (or, for remove, a model OR a dom-id string). count/empty/size are optional — a stream is emitted only for the pieces declared.

render_self is false: the row append/prepend/remove IS the update, so we must NOT also replace the whole container (that would re-render every row and clobber the just-streamed delta).

token_component is the CONTAINER (cosmos#1939): a reply that does NOT re-render self must STILL refresh self's signed token, or the list is add-once-only — correct on the first click, then every subsequent dispatch from the list root is rejected (its token went stale) with no error. The container owns the add/remove trigger, so the endpoint appends its inert reactive:token stream (the same #30 machinery reply.streams uses) to roll the token forward without re-rendering the rows.



78
79
80
81
82
# File 'lib/phlex/reactive/response.rb', line 78

def collection_append(component, name, model)
  definition = collection_def!(component, name)
  new(streams: collection_add_streams(definition, component, model, :append),
    render_self: false, token_component: component)
end

.collection_prepend(component, name, model) ⇒ Object



84
85
86
87
88
# File 'lib/phlex/reactive/response.rb', line 84

def collection_prepend(component, name, model)
  definition = collection_def!(component, name)
  new(streams: collection_add_streams(definition, component, model, :prepend),
    render_self: false, token_component: component)
end

.collection_remove(component, name, model) ⇒ Object



90
91
92
93
94
# File 'lib/phlex/reactive/response.rb', line 90

def collection_remove(component, name, model)
  definition = collection_def!(component, name)
  new(streams: collection_remove_streams(definition, component, model),
    render_self: false, token_component: component)
end

.default_flash_html(level, content, dismiss_after = nil) ⇒ Object

The default string-flash wrapper:

<div class="reactive-flash reactive-flash--{level}"
   data-reactive-flash-level="{level}">{content}</div>

The level is unconditionally HTML-escaped (it lands in a class name and a data attribute). The content keeps the exact pre-#77 injection contract, now applied INSIDE the wrapper: ERB::Util.html_escape escapes a plain String but passes an html_safe String verbatim — the same behavior Turbo's TagBuilder gave the unwrapped string, so a model value still can't inject markup while intentional raw HTML (html_safe) still renders. The wrapper is html_safe by construction (both interpolations are escaped above), so the TagBuilder emits it as-is.



218
219
220
221
222
223
224
225
226
# File 'lib/phlex/reactive/response.rb', line 218

def default_flash_html(level, content, dismiss_after = nil)
  level_attr = CGI.escapeHTML(level.to_s)
  body = ERB::Util.html_escape(content.to_s)

  # html_safe is safe by construction: every interpolated piece is escaped
  # (dismiss_attr coerces to an integer, so it can't inject an attribute).
  %(<div class="reactive-flash reactive-flash--#{level_attr}" \
data-reactive-flash-level="#{level_attr}"#{dismiss_attr(dismiss_after)}>#{body}</div>).html_safe
end

.dismiss_attr(dismiss_after) ⇒ Object

The self-dismiss attribute, or "" when off. The value is forced through to_i so a String/float (or an injection attempt) becomes a plain integer — the client's document-level handler reads it as a timeout in ms.



231
232
233
234
235
# File 'lib/phlex/reactive/response.rb', line 231

def dismiss_attr(dismiss_after)
  return "" if dismiss_after.nil?

  %( data-reactive-dismiss-after="#{dismiss_after.to_i}")
end

.flash_html(level, content, dismiss_after = nil) ⇒ Object

Resolve flash content to HTML, carrying the level (issue #77):

* a Phlex component instance — rendered verbatim, exactly as before
(byte-identical; it also bypasses flash_component).
* a String with Phlex::Reactive.flash_component configured — the
app's component is instantiated new(level:, content:) and
rendered through the existing render_html path.
* a String otherwise — the default level-carrying wrapper below.


192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/phlex/reactive/response.rb', line 192

def flash_html(level, content, dismiss_after = nil)
  # Verbatim Phlex component content owns its own markup (and lifecycle),
  # so dismiss_after has nowhere to hang — it wraps only string content.
  return render_html(content) if content.is_a?(::Phlex::SGML)

  component_class = Phlex::Reactive.flash_component
  if component_class
    html = render_html(component_class.new(level:, content:))
    return dismiss_after ? wrap_dismiss(html, dismiss_after) : html
  end

  default_flash_html(level, content, dismiss_after)
end

.flash_stream(level, content, target:, dismiss_after: nil) ⇒ Object

Build a flash turbo-stream that appends content into a host-app container. content is a Phlex component instance (rendered through the configured renderer so t()/url_for work) or a String — supplied by the caller because the render context is off-request (there is no Rails flash). The LEVEL reaches the wire (issue #77): string content gets a level-carrying wrapper (or the configured Phlex::Reactive.flash_component); component content renders VERBATIM — the caller owns the markup, no wrapper, no double-wrapping.



181
182
183
# File 'lib/phlex/reactive/response.rb', line 181

def flash_stream(level, content, target:, dismiss_after: nil)
  Phlex::Reactive.stream_builder.append(target, html: flash_html(level, content, dismiss_after))
end

.js_ops_json(ops) ⇒ Object

Normalize ops to its JSON wire form, rejecting an empty chain — a reactive:js stream with no ops is a dead stream (a mistake at the call site), so fail loudly rather than emit an inert tag.



269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/phlex/reactive/response.rb', line 269

def js_ops_json(ops)
  if ops.is_a?(Phlex::Reactive::JS)
    raise ArgumentError, "js(...) got no ops — a dead reactive:js stream" if ops.empty?

    ops.to_json
  else
    list = Array(ops)
    raise ArgumentError, "js(...) got no ops — a dead reactive:js stream" if list.empty?

    # The builder validates attr names at build time; a raw [op, args]
    # list skips that, so re-apply the allowlist here (defense in depth —
    # the client interpreter also refuses on*/URL/style attrs).
    Phlex::Reactive::JS.assert_ops_allowed!(list)
    list.to_json
  end
end

.js_stream(ops, target: nil) ⇒ Object

Build a reactive:js turbo-stream carrying server-pushed client DOM ops (issue #97). ops is a Phlex::Reactive::JS chain or a raw [[op, args], ...] array; target (an element id or nil) scopes op resolution on the client (nil → document-scoped). The ops JSON is HTML-escaped into the attribute exactly like to_stream_token — a raw interpolation would be an injection vector. The client's registered reactive:js handler runs the ops through the SAME whitelist interpreter as on_client (default-deny), so an unknown op is warn-and-skipped there.



259
260
261
262
263
264
# File 'lib/phlex/reactive/response.rb', line 259

def js_stream(ops, target: nil)
  json = js_ops_json(ops)
  target_attr = target ? %( target="#{ERB::Util.html_escape(target)}") : ""
  %(<turbo-stream action="reactive:js"#{target_attr} \
data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
end

.morph(component) ⇒ Object

Re-render the component in place via Idiomorph (issue #28). Emits <turbo-stream action="replace" method="morph">, so Turbo 8 morphs the subtree — the focused + caret survive the save. Use this for per-field reactive editing (a "spreadsheet" grid where a debounced save fires while the user is still typing/tabbing). The morphed root still carries the fresh signed token, so the next action verifies.



37
# File 'lib/phlex/reactive/response.rb', line 37

def morph(component) = new(streams: [component.to_stream_morph], subject_component: component)

.redirect(url) ⇒ Object

Client-side full navigation (Turbo.visit). Use when the current URL is dead (slug rename) or the outcome belongs on another page. Pass a *_url (the off-request render context has no request host for *_path).



54
# File 'lib/phlex/reactive/response.rb', line 54

def redirect(url) = new(redirect_url: url, render_self: false)

.remove(component) ⇒ Object

Remove the component's element from the DOM. Uses the instance to_stream_remove (the component already knows its own #id — no class-builder reconstruction; works for record- and state-backed).



49
# File 'lib/phlex/reactive/response.rb', line 49

def remove(component) = new(streams: [component.to_stream_remove], render_self: false)

.render_html(content) ⇒ Object

Resolve content to the HTML for a turbo-stream's html:. Two forms, both SAFE against injection by default:

* a Phlex component instance — rendered through the configured
renderer, which auto-escapes interpolated values.
* any other value — coerced with to_s and handed to Turbo's
TagBuilder, which HTML-ESCAPES a plain String. So a model value
(`html: @record.name`) cannot inject markup. To emit intentional
raw HTML, pass an `html_safe` String (Turbo leaves those verbatim)
or a Phlex component. Same contract as the pre-existing flash_stream.


295
296
297
# File 'lib/phlex/reactive/response.rb', line 295

def render_html(content)
  content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
end

.replace(component, morph: false) ⇒ Object

Re-render the component in place (explicit form of today's default). morph: true morphs the subtree (preserves the focused input + caret) instead of an outerHTML swap — see .morph (issue #28).



26
27
28
29
# File 'lib/phlex/reactive/response.rb', line 26

def replace(component, morph: false)
  new(streams: [morph ? component.to_stream_morph : component.to_stream_replace],
    subject_component: component)
end

.streams(component, *strings) ⇒ Object

Partial / per-field update with a TOKEN-ONLY refresh (issue #30). Emits EXACTLY the given streams — no forced full-self replace — but binds component so the endpoint appends its tiny to_stream_token stream. So the signed token rolls forward (the next action verifies) while the component's own live inputs are never torn down: ideal for a spreadsheet-like grid where a debounced save re-streams only a total cell and the user is still typing in a sibling field.

Response.streams(self, Totals.update(@invoice))   # update only the totals

render_self is false (we do NOT inject the full replace); the token is refreshed by the bound component's token stream instead.



169
170
171
# File 'lib/phlex/reactive/response.rb', line 169

def streams(component, *strings)
  new(streams: strings.flatten, render_self: false, token_component: component)
end

.update(component, morph: false) ⇒ Object

Update only inner HTML (preserves the root element + its token attr). morph: true morphs the inner HTML in place (issue #113) instead of replacing it, so a cross-tab update keeps a peer's focus/caret.



42
43
44
# File 'lib/phlex/reactive/response.rb', line 42

def update(component, morph: false)
  new(streams: [component.to_stream_update(morph:)], subject_component: component)
end

.update_stream(target, content) ⇒ Object

Build a turbo-stream that updates an arbitrary target id with content (a Phlex component instance or an HTML string). Used by #also_update to re-render a companion element that isn't itself a Streamable component.



247
248
249
# File 'lib/phlex/reactive/response.rb', line 247

def update_stream(target, content)
  Phlex::Reactive.stream_builder.update(target, html: render_html(content))
end

.with(*strings) ⇒ Object

Escape hatch / multi-stream root: zero or more raw turbo-stream strings.



57
# File 'lib/phlex/reactive/response.rb', line 57

def with(*strings) = new(streams: strings.flatten)

.wrap_dismiss(html, dismiss_after) ⇒ Object

Wrap already-rendered flash HTML (the flash_component path) in a dismiss-bearing div. SafeBuffer#to_s returns SELF, so concatenate the html_safe pieces (never .to_s + raw) to keep the buffer safe.



240
241
242
# File 'lib/phlex/reactive/response.rb', line 240

def wrap_dismiss(html, dismiss_after)
  (%(<div#{dismiss_attr(dismiss_after)}>).html_safe + html.html_safe + "</div>".html_safe)
end

Instance Method Details

#also_replace(component, morph: false) ⇒ Object

Like #also_update, but renders ANOTHER Streamable component and replaces it by its own #id — for a companion that is itself a component. Response.replace(self).also_replace(SummaryCard.new(order: @order)) morph: true morphs the companion in place (issue #28) — use it when the companion also holds focusable inputs that must survive the re-render.



430
431
432
# File 'lib/phlex/reactive/response.rb', line 430

def also_replace(component, morph: false)
  stream(morph ? component.to_stream_morph : component.to_stream_replace)
end

#also_update(target, html:) ⇒ Object

Also re-render a COMPANION element alongside self — a page heading, a summary card, a badge that recomputes from the saved value (issue #25). target is the sibling element's DOM id. html is either:

* a plain String — HTML-ESCAPED by Turbo, so a model value is safe:
  Response.replace(self).also_update("page_heading", html: @record.name)
* a Phlex component — rendered + auto-escaped through the renderer (use
this when the companion has its own markup), or an `html_safe` String
for intentional raw HTML.

Returns a NEW Response (immutable). The common "re-render self + N siblings" case no longer needs raw turbo_stream_builder.



421
422
423
# File 'lib/phlex/reactive/response.rb', line 421

def also_update(target, html:)
  stream(self.class.update_stream(target, html))
end

#defer(component, placeholder: nil, morph: false) ⇒ Object

Record a DEFERRED segment (issue #165): component's render is taken off the actor's critical path — the reply carries a pending marker (or placeholder:) plus a delivery directive, and the real HTML reaches the SAME actor when the render completes (pull fetch or pgbus push, picked at the endpoint). morph: true makes the arrival morph in place. Returns a NEW Response (immutable). Chain it like any other verb:

reply.streams(volume_cell_stream).defer(SessionTotals.new(workout:))

Dead constructs fail HERE, loudly: defer on a redirect (the client is navigating away), a non-reactive component (its identity could never be rebuilt at render time), a bogus placeholder type.



363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/phlex/reactive/response.rb', line 363

def defer(component, placeholder: nil, morph: false)
  if redirect?
    raise Phlex::Reactive::Error,
      "reply.defer on a redirect reply is dead — the client is navigating away, " \
      "so the deferred render could never land"
  end
  Phlex::Reactive::Defer.validate_segment!(component, placeholder)

  self.class.new(
    streams: @streams,
    redirect_url: @redirect_url,
    render_self: @render_self,
    token_component: @token_component,
    subject_component: @subject_component,
    deferred_segments: @deferred_segments +
      [Phlex::Reactive::Defer::Segment.new(component:, placeholder:, morph:)]
  )
end

#deferred?Boolean

Returns:

  • (Boolean)


336
# File 'lib/phlex/reactive/response.rb', line 336

def deferred? = !@deferred_segments.empty?

#flash(level, content, target: Phlex::Reactive.flash_target, dismiss_after: nil) ⇒ Object

Append a flash turbo-stream into a host-app container (default

, configurable via Phlex::Reactive.flash_target). `dismiss_after:` (ms) makes the flash self-remove after the timeout — a document-level client handler removes any [data-reactive-dismiss-after] container, so it works for reply AND broadcast flashes (issue #100). It wraps only STRING content; a verbatim Phlex component owns its lifecycle.


407
408
409
# File 'lib/phlex/reactive/response.rb', line 407

def flash(level, content, target: Phlex::Reactive.flash_target, dismiss_after: nil)
  stream(self.class.flash_stream(level, content, target:, dismiss_after:))
end

#js(ops, target: :__default) ⇒ Object

Chain a reactive:js op stream — server-pushed client DOM ops (issue #97) — onto this reply, so a focus/dispatch/class toggle runs on the client after the render applies:

reply.morph.js(js.focus("[name=next]").dispatch("app:saved"))

ops is a Phlex::Reactive::JS chain (or a raw [[op, args], ...] array). target (an element id) scopes op resolution on the client; it defaults to the bound component's id (subject_component/token_component) so self-scoped ops (@root, a component-relative selector) just work, and is nil (document-scoped) for a subject-free reply.with. Returns a NEW Response (immutable) — the op stream rides the same stream() plumbing, so the endpoint emits it LAST (after all render streams) and a focus op sees the post-render DOM.



396
397
398
399
# File 'lib/phlex/reactive/response.rb', line 396

def js(ops, target: :__default)
  resolved = target == :__default ? default_js_target : target
  stream(self.class.js_stream(ops, target: resolved))
end

#redirect?Boolean

Returns:

  • (Boolean)


434
# File 'lib/phlex/reactive/response.rb', line 434

def redirect? = !@redirect_url.nil?

#refresh_token?Boolean

True when a partial update (.streams) opted out of the full-self replace but still needs the token rolled forward — the endpoint appends the bound component's tiny token-only stream (issue #30).

Returns:

  • (Boolean)


440
# File 'lib/phlex/reactive/response.rb', line 440

def refresh_token? = !@token_component.nil?

#render_self?Boolean

Returns:

  • (Boolean)


435
# File 'lib/phlex/reactive/response.rb', line 435

def render_self? = @render_self

#stream(*more) ⇒ Object

Append extra turbo-stream strings (a sibling component, a flash). Returns a NEW Response (immutable).



340
341
342
343
344
345
346
347
348
349
# File 'lib/phlex/reactive/response.rb', line 340

def stream(*more)
  self.class.new(
    streams: @streams + more.flatten,
    redirect_url: @redirect_url,
    render_self: @render_self,
    token_component: @token_component,
    subject_component: @subject_component,
    deferred_segments: @deferred_segments
  )
end