Module: Phlex::Reactive

Defined in:
lib/phlex/reactive.rb,
lib/phlex/reactive/reply.rb,
lib/phlex/reactive/engine.rb,
lib/phlex/reactive/version.rb,
lib/phlex/reactive/response.rb,
lib/phlex/reactive/component.rb,
lib/phlex/reactive/streamable.rb,
app/controllers/phlex/reactive/actions_controller.rb,
lib/generators/phlex/reactive/install/install_generator.rb,
lib/generators/phlex/reactive/component/component_generator.rb

Overview

phlex-reactive: reactive Phlex components for Rails.

Two cooperating mixins, one client runtime, one endpoint:

* Phlex::Reactive::Streamable — gives a component a stable `id` and class
methods to render itself as a Turbo Stream (`.replace`, `.append`, ...)
and to broadcast itself (`.broadcast_replace_to`, ...). The server->client
half (controller responses + background broadcasts).

* Phlex::Reactive::Component — declares client-invokable `action`s and
emits a signed identity token + the wiring the generic `reactive`
Stimulus controller needs. The client->server half (clicks, form input).

Both halves converge on ONE re-render unit: the component, targeted by its id. See the README for the mental model and examples.

Defined Under Namespace

Modules: Component, Generators, Streamable Classes: ActionsController, Engine, Error, InvalidToken, Reply, Response

Constant Summary collapse

IDENTITY_PURPOSE =

Purpose string bound into every identity token's signature so a token minted for phlex-reactive can't be replayed against another verifier use.

"phlex-reactive/identity"
ACTIONS_CONTROLLER =

The controller a correctly-mounted action path resolves to. Used by the route guard below.

"phlex/reactive/actions"
VERSION =
"0.4.5"

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.action_pathObject



69
70
71
# File 'lib/phlex/reactive.rb', line 69

def action_path
  @action_path ||= "/reactive/actions"
end

.authorization_errorsObject

Exception classes the action endpoint renders as 403. Append your authorization library's error (Pundit::NotAuthorizedError, ActionPolicy::Unauthorized, ...).



61
62
63
# File 'lib/phlex/reactive.rb', line 61

def authorization_errors
  @authorization_errors
end

.base_controller_nameObject



177
178
179
# File 'lib/phlex/reactive.rb', line 177

def base_controller_name
  @base_controller_name ||= "ActionController::Base"
end

.flash_targetObject

DOM id of the host-app container a Response#flash appends into. Default "flash"; override to match your layout's flash region.



107
108
109
# File 'lib/phlex/reactive.rb', line 107

def flash_target
  @flash_target ||= "flash"
end

.rendererObject



77
78
79
# File 'lib/phlex/reactive.rb', line 77

def renderer
  @renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
end

.verifierObject



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

def verifier
  @verifier ||= default_verifier
end

Class Method Details

.action_route_ok?(path = action_path) ⇒ Boolean

True when a POST to path resolves to the gem's ActionsController. A host catch-all route (match "*path", ...) appended above the engine's route SHADOWS it, so every reactive POST 404s and none of the controller runs — the opaque "is the endpoint even mounted?" failure (issue #26). A false here is the signal. Returns false (not raise) when nothing matches.

Returns:

  • (Boolean)


221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/phlex/reactive.rb', line 221

def action_route_ok?(path = action_path)
  return false unless defined?(::Rails) && ::Rails.application

  # At after_initialize (when the boot guard runs) the host's routes may not
  # be drawn yet, so recognize_path would see an incomplete set and report a
  # false shadow. Force-load routes first (idempotent — no-op if already
  # loaded), so the check is correct whether it runs at boot or at runtime.
  ensure_routes_loaded
  recognized = ::Rails.application.routes.recognize_path(path, method: :post)
  recognized[:controller] == ACTIONS_CONTROLLER
rescue ActionController::RoutingError, ActiveRecord::RecordNotFound
  false
end

.base_controllerObject



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

def base_controller
  base_controller_name.constantize
end

.current_connection_idObject

The acting client's SSE connection id during an action, or nil. Set by the ActionsController from the X-Pgbus-Connection header. A component action passes exclude: Phlex::Reactive.current_connection_id (or the reactive_connection_id helper) to suppress the actor's own broadcast echo.



200
201
202
# File 'lib/phlex/reactive.rb', line 200

def current_connection_id
  Thread.current[:phlex_reactive_connection_id]
end

.flash_builderObject

A Turbo::Streams::TagBuilder bound to an off-request view context, used to build standalone streams (e.g. a Response flash append) not tied to a specific component's id. Cached PER THREAD alongside the context it's bound to (see off_request_view_context for why per-thread).



127
128
129
# File 'lib/phlex/reactive.rb', line 127

def flash_builder
  off_request_view_context_cache[:builder]
end

.off_request_view_contextObject

The off-request view context for the current thread, built once and reused for both the flash builder and standalone component renders. Cached PER THREAD, not per process: an ActionView context carries mutable output_buffer/view_flow state (render_in's capture swaps it), so sharing one instance across threads can interleave content on a threaded server. Rebuilt when the renderer object changes or the generation is bumped (reset_flash_builder! / Rails code reload), so a reloaded controller is never served stale.



139
140
141
# File 'lib/phlex/reactive.rb', line 139

def off_request_view_context
  off_request_view_context_cache[:view_context]
end

.off_request_view_context_generationObject



151
152
153
# File 'lib/phlex/reactive.rb', line 151

def off_request_view_context_generation
  @off_request_view_context_generation ||= 0
end

.render(component) ⇒ Object

Render a Phlex component to HTML with a full (off-request) view context. Uses phlex-rails' #render_in against the memoized view context — a direct component.call that skips ActionController's renderer.render machinery (~2x faster, ~half the allocations), with the same HTML and full helper access (dom_id/url_for/t/csrf). Used for a Phlex component embedded as Response#with content.



119
120
121
# File 'lib/phlex/reactive.rb', line 119

def render(component)
  component.render_in(off_request_view_context)
end

.request_bound_view_context(controller_class) ⇒ Object

Build an off-request view context whose controller has a REAL request.

A bare controller.new.view_context (the naive off-request context) has request == nil, so any request-dependent helper raises undefined method 'env' for nil — form_authenticity_token, protect_against_forgery?, and host-aware URL helpers all read request.env (issue #42). We replicate exactly what ActionController::Renderer#render does to set up its mock request — build an ActionDispatch::Request from the renderer's env (which derives the host from the routes' default_url_options), bind the routes, and attach it — then return the controller's view context instead of rendering a template. The result keeps the 0.4.0 render_in speedup (no renderer.render machinery) while restoring the request those helpers need.



94
95
96
97
98
99
100
101
102
103
# File 'lib/phlex/reactive.rb', line 94

def request_bound_view_context(controller_class)
  ar_renderer = controller_class.renderer
  request = ::ActionDispatch::Request.new(ar_renderer.send(:env_for_request))
  request.routes = controller_class._routes

  instance = controller_class.new
  instance.set_request!(request)
  instance.set_response!(controller_class.make_response!(request))
  instance.view_context
end

.reset_flash_builder!Object

Invalidate the per-thread context + builder for ALL threads by bumping the generation; each thread rebuilds lazily on next use. Registered on Rails' reloader by the engine; also used by specs. Thread-safe (an integer bump, no shared structure to tear down).



147
148
149
# File 'lib/phlex/reactive.rb', line 147

def reset_flash_builder!
  @off_request_view_context_generation = off_request_view_context_generation + 1
end

.sign(payload) ⇒ Object

Signs a payload hash into an identity token.



191
192
193
# File 'lib/phlex/reactive.rb', line 191

def sign(payload)
  verifier.generate(payload, purpose: IDENTITY_PURPOSE)
end

.verify(token) ⇒ Object

Returns the verified payload hash, or nil if the token is invalid.



186
187
188
# File 'lib/phlex/reactive.rb', line 186

def verify(token)
  verifier.verified(token, purpose: IDENTITY_PURPOSE)
end

.warn_unless_action_route_mounted!(path: action_path, logger: default_logger) ⇒ Object

Log a clear warning (once, at boot) when the action path doesn't resolve to the gem controller — pointing at the catch-all shadow rather than leaving an adopter to guess. Called from the engine's after_initialize.



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

def warn_unless_action_route_mounted!(path: action_path, logger: default_logger)
  return if action_route_ok?(path)
  return unless logger

  logger.warn(
    "[phlex-reactive] POST #{path} does not resolve to #{ACTIONS_CONTROLLER}. " \
    "A host catch-all route (e.g. match \"*path\", ...) likely shadows it, so reactive " \
    "actions will 404. Exempt #{path.delete_prefix("/")} from the catch-all, or set " \
    "Phlex::Reactive.action_path to an unshadowed path. See the README integration section."
  )
end

.with_connection_id(connection_id) ⇒ Object



204
205
206
207
208
209
210
# File 'lib/phlex/reactive.rb', line 204

def with_connection_id(connection_id)
  previous = Thread.current[:phlex_reactive_connection_id]
  Thread.current[:phlex_reactive_connection_id] = connection_id.presence
  yield
ensure
  Thread.current[:phlex_reactive_connection_id] = previous
end