phlex-reactive 0.3.0 → 0.4.1

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.
@@ -26,6 +26,41 @@ module Phlex
26
26
  module Streamable
27
27
  extend ActiveSupport::Concern
28
28
 
29
+ # A per-thread cache entry: an off-request view context + the Turbo
30
+ # TagBuilder bound to it, tagged with the renderer + generation it was
31
+ # built for. Rebuilt on a thread when the renderer object changes or the
32
+ # class generation is bumped — so a reset/reload/renderer-swap is picked
33
+ # up without ever sharing a mutable context across threads.
34
+ ThreadViewContext = Struct.new(:view_context, :builder, :renderer, :generation)
35
+
36
+ # Every class that includes Streamable, so the engine can flush their
37
+ # memoized view contexts on a Rails code reload (dev) in one pass. A
38
+ # WeakMap used as a set (the class is the key) so a class reloaded by
39
+ # Zeitwerk in dev is GC'd normally — a strong Array would pin every
40
+ # reloaded generation and leak across reloads. Guarded by a mutex;
41
+ # registration happens at class-load time, the reset iterates under lock.
42
+ @registry = ObjectSpace::WeakMap.new
43
+ @registry_mutex = Mutex.new
44
+
45
+ class << self
46
+ # Reset every streamable class's cached view context + builder. Called
47
+ # from the engine's reloader (config.to_prepare) so a reloaded renderer
48
+ # controller is never served from a stale memo. No-op outside Rails.
49
+ def reset_all_view_contexts!
50
+ @registry_mutex.synchronize do
51
+ @registry.each_key(&:reset_turbo_view_context!)
52
+ end
53
+ end
54
+
55
+ def register(klass)
56
+ @registry_mutex.synchronize { @registry[klass] = true }
57
+ end
58
+ end
59
+
60
+ included do
61
+ Phlex::Reactive::Streamable.register(self)
62
+ end
63
+
29
64
  class_methods do
30
65
  # The keyword the positional model maps to in `initialize`. For a
31
66
  # record-backed component (Component#reactive_record), this is the SAME
@@ -43,25 +78,62 @@ module Phlex
43
78
  end
44
79
 
45
80
  def component_args(model, options)
46
- {model_param_name => model}.merge(options)
81
+ { model_param_name => model }.merge(options)
47
82
  end
48
83
 
49
84
  # Turbo::Streams::TagBuilder needs a real VIEW CONTEXT (it calls
50
85
  # `.formats` on it), not a controller class. Build one off-request from
51
- # the configured renderer controller. Memoized per class.
86
+ # the configured renderer controller, bound to a real request so
87
+ # request-dependent helpers (form_authenticity_token, host-aware URL
88
+ # helpers) work during a re-render/broadcast (issue #42). Memoized PER
89
+ # THREAD — building a
90
+ # view context instantiates the renderer controller and assembles its
91
+ # whole helper module set, so doing it per render/broadcast was the
92
+ # hottest server-side allocation in the action round trip. The builder
93
+ # is bound to that one context and reused too.
94
+ #
95
+ # Why per-thread and not one-per-process: an ActionView context carries
96
+ # MUTABLE state (output_buffer/view_flow that #render_in's capture
97
+ # swaps), so a single instance shared across threads can interleave or
98
+ # bleed content between overlapping renders on a threaded server (Puma)
99
+ # or in concurrent jobs. A thread-local cache keeps the amortization
100
+ # (built once per thread, reused across that thread's renders) without
101
+ # ever sharing a buffer across threads.
52
102
  def turbo_stream_builder
53
- ::Turbo::Streams::TagBuilder.new(turbo_view_context)
103
+ thread_view_context_cache.builder
54
104
  end
55
105
 
106
+ # The off-request view context for the current thread, built once and
107
+ # reused. Rebuilt when the configured renderer object changes (a swap of
108
+ # Phlex::Reactive.renderer) or when the class's view-context generation
109
+ # is bumped (reset_turbo_view_context! / Rails code reload), so a
110
+ # reloaded controller class is never served stale.
56
111
  def turbo_view_context
57
- renderer.new.view_context
112
+ thread_view_context_cache.view_context
113
+ end
114
+
115
+ # Invalidate the cached view context + builder for ALL threads by bumping
116
+ # this class's generation — each thread rebuilds lazily on its next use,
117
+ # and entries for the old generation are dropped. Registered on Rails'
118
+ # reloader by the engine; also used by specs. Thread-safe (an integer
119
+ # bump; no shared mutable structure to tear down).
120
+ def reset_turbo_view_context!
121
+ @turbo_view_context_generation = turbo_view_context_generation + 1
122
+ end
123
+
124
+ def turbo_view_context_generation
125
+ @turbo_view_context_generation ||= 0
58
126
  end
59
127
 
60
- # Render a component to HTML with a full Rails view context. Routing
61
- # through the controller renderer keeps dom_id/url_for/t() working
62
- # during a re-render or broadcast.
128
+ # Render a component to HTML with a full Rails view context. Uses
129
+ # phlex-rails' #render_in against our memoized off-request view context
130
+ # a direct component.call that skips ActionController's renderer.render
131
+ # machinery (TemplateRenderer, LookupContext, the render log subscriber):
132
+ # ~2x faster with ~half the allocations, and byte-identical HTML. The
133
+ # view context still carries the full Rails helper set, so
134
+ # dom_id/url_for/t()/csrf keep working during a re-render or broadcast.
63
135
  def render_component(component)
64
- renderer.render(component, layout: false)
136
+ component.render_in(turbo_view_context)
65
137
  end
66
138
 
67
139
  # `morph: true` emits `<turbo-stream action="replace" method="morph">` so
@@ -161,14 +233,14 @@ module Phlex
161
233
  # `method: :morph` to emit the `method="morph"` attribute. Pass it ONLY
162
234
  # when morphing, so the default call produces today's plain replace.
163
235
  def morph_method(morph)
164
- morph ? {method: :morph} : {}
236
+ morph ? { method: :morph } : {}
165
237
  end
166
238
 
167
239
  # The BROADCAST path renders extra <turbo-stream> attributes through
168
240
  # `attributes:` (it has no `method:` kwarg — that would fall into the
169
241
  # render args and be dropped). Same wire result: method="morph".
170
242
  def morph_attributes(morph)
171
- morph ? {attributes: {method: "morph"}} : {}
243
+ morph ? { attributes: { method: "morph" } } : {}
172
244
  end
173
245
 
174
246
  # Only include transport opts that were actually given, so on Action
@@ -183,6 +255,25 @@ module Phlex
183
255
  def renderer
184
256
  Phlex::Reactive.renderer
185
257
  end
258
+
259
+ def thread_view_context_cache
260
+ store = (Thread.current[:phlex_reactive_view_contexts] ||= {})
261
+ current = renderer
262
+ generation = turbo_view_context_generation
263
+ cached = store[self]
264
+
265
+ unless cached && cached.renderer.equal?(current) && cached.generation == generation
266
+ view_context = Phlex::Reactive.request_bound_view_context(current)
267
+ cached = ThreadViewContext.new(
268
+ view_context,
269
+ ::Turbo::Streams::TagBuilder.new(view_context),
270
+ current,
271
+ generation
272
+ )
273
+ store[self] = cached
274
+ end
275
+ cached
276
+ end
186
277
  end
187
278
 
188
279
  # Required: the stable DOM id used as the Turbo Stream target. It MUST
@@ -233,8 +324,16 @@ module Phlex
233
324
  # The component carries its token via Component#reactive_token; a Streamable
234
325
  # that isn't a Component (no token) simply has nothing to refresh — guarded
235
326
  # by respond_to? so the primitive stays usable on a bare Streamable.
327
+ #
328
+ # respond_to? MUST include private methods (the `true` arg): Component
329
+ # defines `reactive_token` as PRIVATE, so a plain `respond_to?(:reactive_token)`
330
+ # is false for every Component and the stream silently carries an EMPTY token —
331
+ # which makes any non-self-rendering reply (reply.streams #30, reply.append /
332
+ # reply.remove #35) add-once-only: the first action works, then the stale (here
333
+ # empty) token is rejected on the next dispatch (cosmos#1939). A bare Streamable
334
+ # has no reactive_token method at all, so it still returns false correctly.
236
335
  def to_stream_token
237
- token = respond_to?(:reactive_token) ? reactive_token : nil
336
+ token = respond_to?(:reactive_token, true) ? reactive_token : nil
238
337
  %(<turbo-stream action="reactive:token" target="#{ERB::Util.html_escape(id)}" data-reactive-token-value="#{ERB::Util.html_escape(token)}"></turbo-stream>)
239
338
  end
240
339
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.1"
6
6
  end
7
7
  end
@@ -78,6 +78,30 @@ module Phlex
78
78
  @renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
79
79
  end
80
80
 
81
+ # Build an off-request view context whose controller has a REAL `request`.
82
+ #
83
+ # A bare `controller.new.view_context` (the naive off-request context) has
84
+ # `request == nil`, so any request-dependent helper raises
85
+ # `undefined method 'env' for nil` — form_authenticity_token,
86
+ # protect_against_forgery?, and host-aware URL helpers all read
87
+ # `request.env` (issue #42). We replicate exactly what
88
+ # ActionController::Renderer#render does to set up its mock request — build
89
+ # an ActionDispatch::Request from the renderer's env (which derives the host
90
+ # from the routes' default_url_options), bind the routes, and attach it —
91
+ # then return the controller's view context instead of rendering a template.
92
+ # The result keeps the 0.4.0 render_in speedup (no renderer.render
93
+ # machinery) while restoring the request those helpers need.
94
+ def request_bound_view_context(controller_class)
95
+ ar_renderer = controller_class.renderer
96
+ request = ::ActionDispatch::Request.new(ar_renderer.send(:env_for_request))
97
+ request.routes = controller_class._routes
98
+
99
+ instance = controller_class.new
100
+ instance.set_request!(request)
101
+ instance.set_response!(controller_class.make_response!(request))
102
+ instance.view_context
103
+ end
104
+
81
105
  # DOM id of the host-app container a Response#flash appends into.
82
106
  # Default "flash"; override to match your layout's flash region.
83
107
  def flash_target
@@ -87,17 +111,69 @@ module Phlex
87
111
  attr_writer :flash_target
88
112
 
89
113
  # Render a Phlex component to HTML with a full (off-request) view context.
114
+ # Uses phlex-rails' #render_in against the memoized view context — a direct
115
+ # component.call that skips ActionController's renderer.render machinery
116
+ # (~2x faster, ~half the allocations), with the same HTML and full helper
117
+ # access (dom_id/url_for/t/csrf). Used for a Phlex component embedded as
118
+ # Response#with content.
90
119
  def render(component)
91
- renderer.render(component, layout: false)
120
+ component.render_in(off_request_view_context)
92
121
  end
93
122
 
94
123
  # A Turbo::Streams::TagBuilder bound to an off-request view context, used
95
124
  # to build standalone streams (e.g. a Response flash append) not tied to a
96
- # specific component's id.
125
+ # specific component's id. Cached PER THREAD alongside the context it's
126
+ # bound to (see off_request_view_context for why per-thread).
97
127
  def flash_builder
98
- ::Turbo::Streams::TagBuilder.new(renderer.new.view_context)
128
+ off_request_view_context_cache[:builder]
129
+ end
130
+
131
+ # The off-request view context for the current thread, built once and
132
+ # reused for both the flash builder and standalone component renders.
133
+ # Cached PER THREAD, not per process: an ActionView context carries mutable
134
+ # output_buffer/view_flow state (render_in's capture swaps it), so sharing
135
+ # one instance across threads can interleave content on a threaded server.
136
+ # Rebuilt when the renderer object changes or the generation is bumped
137
+ # (reset_flash_builder! / Rails code reload), so a reloaded controller is
138
+ # never served stale.
139
+ def off_request_view_context
140
+ off_request_view_context_cache[:view_context]
141
+ end
142
+
143
+ # Invalidate the per-thread context + builder for ALL threads by bumping
144
+ # the generation; each thread rebuilds lazily on next use. Registered on
145
+ # Rails' reloader by the engine; also used by specs. Thread-safe (an
146
+ # integer bump, no shared structure to tear down).
147
+ def reset_flash_builder!
148
+ @off_request_view_context_generation = off_request_view_context_generation + 1
149
+ end
150
+
151
+ def off_request_view_context_generation
152
+ @off_request_view_context_generation ||= 0
99
153
  end
100
154
 
155
+ private
156
+
157
+ def off_request_view_context_cache
158
+ cache = Thread.current[:phlex_reactive_off_request_view_context]
159
+ current = renderer
160
+ generation = off_request_view_context_generation
161
+
162
+ unless cache && cache[:renderer].equal?(current) && cache[:generation] == generation
163
+ view_context = request_bound_view_context(current)
164
+ cache = {
165
+ view_context: view_context,
166
+ builder: ::Turbo::Streams::TagBuilder.new(view_context),
167
+ renderer: current,
168
+ generation: generation
169
+ }
170
+ Thread.current[:phlex_reactive_off_request_view_context] = cache
171
+ end
172
+ cache
173
+ end
174
+
175
+ public
176
+
101
177
  def base_controller_name
102
178
  @base_controller_name ||= "ActionController::Base"
103
179
  end
@@ -166,7 +242,7 @@ module Phlex
166
242
  logger.warn(
167
243
  "[phlex-reactive] POST #{path} does not resolve to #{ACTIONS_CONTROLLER}. " \
168
244
  "A host catch-all route (e.g. match \"*path\", ...) likely shadows it, so reactive " \
169
- "actions will 404. Exempt #{path.sub(%r{\A/}, "")} from the catch-all, or set " \
245
+ "actions will 404. Exempt #{path.delete_prefix("/")} from the catch-all, or set " \
170
246
  "Phlex::Reactive.action_path to an unshadowed path. See the README integration section."
171
247
  )
172
248
  end
@@ -225,4 +301,4 @@ loader.ignore("#{lib}/generators")
225
301
  loader.do_not_eager_load("#{__dir__}/reactive/engine.rb")
226
302
  loader.setup
227
303
 
228
- require "phlex/reactive/engine" if defined?(::Rails::Engine)
304
+ require "phlex/reactive/engine" if defined?(Rails::Engine)
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.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -148,7 +148,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
148
148
  requirements:
149
149
  - - ">="
150
150
  - !ruby/object:Gem::Version
151
- version: 3.2.0
151
+ version: 3.4.0
152
152
  required_rubygems_version: !ruby/object:Gem::Requirement
153
153
  requirements:
154
154
  - - ">="