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).

An action builds one through reply (issue #182 — the ONE door; the former Response.* class verbs are removed and raise a guided rewrite):

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

Constant Summary collapse

REMOVED_CLASS_VERBS =

Issue #182: reply is the ONE documented door. Each former public class verb (Response.replace(self), …) is removed — it raises a guided ArgumentError naming the reply.<verb> rewrite. The builder BODIES live on as internal-use build_* class methods (public in Ruby terms — Reply calls them directly — but NOT part of the documented reply-facing surface; Response stays the single place that knows how to construct itself). The rewrite shown for a collection verb points at the keyword form (issue #182).

{
  replace: "reply.replace",
  morph: "reply.morph",
  update: "reply.update",
  remove: "reply.remove",
  redirect: "reply.redirect(url)",
  with: "reply.with(*streams)",
  streams: "reply.streams(*streams)",
  collection_append: "reply.append(model, to: :name)",
  collection_prepend: "reply.prepend(model, to: :name)",
  collection_remove: "reply.remove(model, from: :name)"
}.freeze
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.



374
375
376
377
378
379
380
381
382
383
# File 'lib/phlex/reactive/response.rb', line 374

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.



389
390
391
# File 'lib/phlex/reactive/response.rb', line 389

def deferred_segments
  @deferred_segments
end

#redirect_urlObject (readonly)

Returns the value of attribute redirect_url.



23
24
25
# File 'lib/phlex/reactive/response.rb', line 23

def redirect_url
  @redirect_url
end

#streamsObject (readonly)

Returns the value of attribute streams.



23
24
25
# File 'lib/phlex/reactive/response.rb', line 23

def streams
  @streams
end

#subject_componentObject (readonly)

Returns the value of attribute subject_component.



23
24
25
# File 'lib/phlex/reactive/response.rb', line 23

def subject_component
  @subject_component
end

#token_componentObject (readonly)

Returns the value of attribute token_component.



23
24
25
# File 'lib/phlex/reactive/response.rb', line 23

def token_component
  @token_component
end

Class Method Details

.build_collection_append(component, name, model, effect: nil, **row_kwargs) ⇒ 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. effect: (issue #215) stamps the ROW stream only — the count companion and the empty-state toggle are bookkeeping, not the thing entering/leaving.



122
123
124
125
126
# File 'lib/phlex/reactive/response.rb', line 122

def build_collection_append(component, name, model, effect: nil, **row_kwargs)
  definition = collection_def!(component, name)
  new(streams: collection_add_streams(definition, component, model, :append, row_kwargs, effect:),
    render_self: false, token_component: component)
end

.build_collection_prepend(component, name, model, effect: nil, **row_kwargs) ⇒ Object



128
129
130
131
132
# File 'lib/phlex/reactive/response.rb', line 128

def build_collection_prepend(component, name, model, effect: nil, **row_kwargs)
  definition = collection_def!(component, name)
  new(streams: collection_add_streams(definition, component, model, :prepend, row_kwargs, effect:),
    render_self: false, token_component: component)
end

.build_collection_remove(component, name, model, effect: nil) ⇒ Object



134
135
136
137
138
# File 'lib/phlex/reactive/response.rb', line 134

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

.build_morph(component, effect: nil) ⇒ 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.



74
75
76
# File 'lib/phlex/reactive/response.rb', line 74

def build_morph(component, effect: nil)
  new(streams: [component.to_stream_replace(morph: true, effect:)], subject_component: component)
end

.build_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).



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

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

.build_remove(component, effect: nil) ⇒ 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).



88
89
90
# File 'lib/phlex/reactive/response.rb', line 88

def build_remove(component, effect: nil)
  new(streams: [component.to_stream_remove(effect:)], render_self: false)
end

.build_replace(component, morph: false, effect: nil) ⇒ 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 .build_morph (issue #28). effect: (issue #215) stamps the per-call effect override on the emitted stream — same on every self-targeting builder below.



63
64
65
66
# File 'lib/phlex/reactive/response.rb', line 63

def build_replace(component, morph: false, effect: nil)
  new(streams: [component.to_stream_replace(morph:, effect:)],
    subject_component: component)
end

.build_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.

reply.streams(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.



215
216
217
# File 'lib/phlex/reactive/response.rb', line 215

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

.build_update(component, morph: false, effect: nil) ⇒ 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.



81
82
83
# File 'lib/phlex/reactive/response.rb', line 81

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

.build_with(*strings) ⇒ Object

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



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

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

.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.



266
267
268
269
270
271
272
273
274
# File 'lib/phlex/reactive/response.rb', line 266

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.



286
287
288
289
290
# File 'lib/phlex/reactive/response.rb', line 286

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.


238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/phlex/reactive/response.rb', line 238

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)

  builder = Phlex::Reactive.flash_component
  if builder
    # The app-owned callable maps (level, content) to its own component —
    # the gem no longer guesses kwargs (issue #182).
    html = render_html(builder.call(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.



227
228
229
# File 'lib/phlex/reactive/response.rb', line 227

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.



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
# File 'lib/phlex/reactive/response.rb', line 324

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.



314
315
316
317
318
319
# File 'lib/phlex/reactive/response.rb', line 314

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

.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.


350
351
352
# File 'lib/phlex/reactive/response.rb', line 350

def render_html(content)
  content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
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 to re-render a companion element that isn't itself a Streamable component.



302
303
304
# File 'lib/phlex/reactive/response.rb', line 302

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

.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.



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

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

Instance Method Details

#also(companion = :__none, morph: false, **targets) ⇒ Object

Also re-render COMPANION elements alongside self (issue #182 — one door, replacing also_update + also_replace). The ARGUMENT TYPE picks the Turbo action; there is no verb to choose:

.also(SummaryCard.new(order: @order))          -> REPLACE at the component's own #id
.also(SummaryCard.new(order: @order), morph:)  -> the same, MORPHED in place
.also(page_heading: @record.name)              -> UPDATE (inner HTML) of id "page_heading"
.also("nav-badge" => @record.name)             -> UPDATE of id "nav-badge" (dashed id)

So the two shapes are:

* a Streamable COMPONENT (positional) — a `replace` targeting its OWN #id
(it self-targets, like every reactive component). `morph: true` morphs
it in place (issue #28) when it holds focusable inputs to preserve.
* target => content pairs — an `update` (inner HTML) of each id. The id
need NOT be a reactive component (it's any element), so the only sound
swap is its inner HTML. Content is a plain String (HTML-ESCAPED by
Turbo, so a model value is safe), a Phlex component (rendered +
auto-escaped), or an html_safe String for intentional raw HTML. Written
brace-less as keywords or as an explicit Hash.

This is NOT for collection rows — a row add/remove goes through reply.append(model, to: :name) / reply.remove(id, from: :name), which also emit the count + empty-state streams. .also only re-renders EXISTING companion elements.

Returns a NEW Response (immutable). companion is EITHER a positional component OR target=>content pairs — never both (that raises). The brace-less keyword form lands in **targets; an explicit Hash lands in companion. morph: is reserved for the component form.



496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
# File 'lib/phlex/reactive/response.rb', line 496

def also(companion = :__none, morph: false, **targets)
  if companion != :__none && !targets.empty?
    raise ArgumentError, "reply.also takes EITHER a component OR target => content pairs, not both"
  end

  case companion
  when :__none
    also_targets(targets)
  when ::Phlex::SGML
    stream(companion.to_stream_replace(morph:))
  when Hash
    also_targets(companion)
  else
    raise ArgumentError,
      "reply.also expects target => content pairs or a Streamable component, got #{companion.class}"
  end
end

#also_replaceObject

Raises:

  • (ArgumentError)


521
522
523
524
# File 'lib/phlex/reactive/response.rb', line 521

def also_replace(*, **)
  raise ArgumentError,
    "also_replace was removed in issue #182 — use also(component, morph: …)"
end

#also_update(target) ⇒ Object

Issue #182: also_update / also_replace collapsed into #also. Guided errors naming the rewrite (the html: kwarg lied — it accepted components too).

Raises:

  • (ArgumentError)


516
517
518
519
# File 'lib/phlex/reactive/response.rb', line 516

def also_update(target, **)
  raise ArgumentError,
    "also_update was removed in issue #182 — use also(#{target.inspect} => content)"
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.



418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/phlex/reactive/response.rb', line 418

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)


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

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.


462
463
464
# File 'lib/phlex/reactive/response.rb', line 462

def flash(level, content, target: Phlex::Reactive.flash_target, dismiss_after: nil)
  stream(self.class.send(: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.



451
452
453
454
# File 'lib/phlex/reactive/response.rb', line 451

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)


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

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)


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

def refresh_token? = !@token_component.nil?

#render_self?Boolean

Returns:

  • (Boolean)


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

def render_self? = @render_self

#stream(*more) ⇒ Object

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



395
396
397
398
399
400
401
402
403
404
# File 'lib/phlex/reactive/response.rb', line 395

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