phlex-reactive 0.3.0 → 0.4.0

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,59 @@ 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. Memoized PER THREAD — building a
87
+ # view context instantiates the renderer controller and assembles its
88
+ # whole helper module set, so doing it per render/broadcast was the
89
+ # hottest server-side allocation in the action round trip. The builder
90
+ # is bound to that one context and reused too.
91
+ #
92
+ # Why per-thread and not one-per-process: an ActionView context carries
93
+ # MUTABLE state (output_buffer/view_flow that #render_in's capture
94
+ # swaps), so a single instance shared across threads can interleave or
95
+ # bleed content between overlapping renders on a threaded server (Puma)
96
+ # or in concurrent jobs. A thread-local cache keeps the amortization
97
+ # (built once per thread, reused across that thread's renders) without
98
+ # ever sharing a buffer across threads.
52
99
  def turbo_stream_builder
53
- ::Turbo::Streams::TagBuilder.new(turbo_view_context)
100
+ thread_view_context_cache.builder
54
101
  end
55
102
 
103
+ # The off-request view context for the current thread, built once and
104
+ # reused. Rebuilt when the configured renderer object changes (a swap of
105
+ # Phlex::Reactive.renderer) or when the class's view-context generation
106
+ # is bumped (reset_turbo_view_context! / Rails code reload), so a
107
+ # reloaded controller class is never served stale.
56
108
  def turbo_view_context
57
- renderer.new.view_context
109
+ thread_view_context_cache.view_context
110
+ end
111
+
112
+ # Invalidate the cached view context + builder for ALL threads by bumping
113
+ # this class's generation — each thread rebuilds lazily on its next use,
114
+ # and entries for the old generation are dropped. Registered on Rails'
115
+ # reloader by the engine; also used by specs. Thread-safe (an integer
116
+ # bump; no shared mutable structure to tear down).
117
+ def reset_turbo_view_context!
118
+ @turbo_view_context_generation = turbo_view_context_generation + 1
119
+ end
120
+
121
+ def turbo_view_context_generation
122
+ @turbo_view_context_generation ||= 0
58
123
  end
59
124
 
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.
125
+ # Render a component to HTML with a full Rails view context. Uses
126
+ # phlex-rails' #render_in against our memoized off-request view context
127
+ # a direct component.call that skips ActionController's renderer.render
128
+ # machinery (TemplateRenderer, LookupContext, the render log subscriber):
129
+ # ~2x faster with ~half the allocations, and byte-identical HTML. The
130
+ # view context still carries the full Rails helper set, so
131
+ # dom_id/url_for/t()/csrf keep working during a re-render or broadcast.
63
132
  def render_component(component)
64
- renderer.render(component, layout: false)
133
+ component.render_in(turbo_view_context)
65
134
  end
66
135
 
67
136
  # `morph: true` emits `<turbo-stream action="replace" method="morph">` so
@@ -161,14 +230,14 @@ module Phlex
161
230
  # `method: :morph` to emit the `method="morph"` attribute. Pass it ONLY
162
231
  # when morphing, so the default call produces today's plain replace.
163
232
  def morph_method(morph)
164
- morph ? {method: :morph} : {}
233
+ morph ? { method: :morph } : {}
165
234
  end
166
235
 
167
236
  # The BROADCAST path renders extra <turbo-stream> attributes through
168
237
  # `attributes:` (it has no `method:` kwarg — that would fall into the
169
238
  # render args and be dropped). Same wire result: method="morph".
170
239
  def morph_attributes(morph)
171
- morph ? {attributes: {method: "morph"}} : {}
240
+ morph ? { attributes: { method: "morph" } } : {}
172
241
  end
173
242
 
174
243
  # Only include transport opts that were actually given, so on Action
@@ -183,6 +252,25 @@ module Phlex
183
252
  def renderer
184
253
  Phlex::Reactive.renderer
185
254
  end
255
+
256
+ def thread_view_context_cache
257
+ store = (Thread.current[:phlex_reactive_view_contexts] ||= {})
258
+ current = renderer
259
+ generation = turbo_view_context_generation
260
+ cached = store[self]
261
+
262
+ unless cached && cached.renderer.equal?(current) && cached.generation == generation
263
+ view_context = current.new.view_context
264
+ cached = ThreadViewContext.new(
265
+ view_context,
266
+ ::Turbo::Streams::TagBuilder.new(view_context),
267
+ current,
268
+ generation
269
+ )
270
+ store[self] = cached
271
+ end
272
+ cached
273
+ end
186
274
  end
187
275
 
188
276
  # Required: the stable DOM id used as the Turbo Stream target. It MUST
@@ -233,8 +321,16 @@ module Phlex
233
321
  # The component carries its token via Component#reactive_token; a Streamable
234
322
  # that isn't a Component (no token) simply has nothing to refresh — guarded
235
323
  # by respond_to? so the primitive stays usable on a bare Streamable.
324
+ #
325
+ # respond_to? MUST include private methods (the `true` arg): Component
326
+ # defines `reactive_token` as PRIVATE, so a plain `respond_to?(:reactive_token)`
327
+ # is false for every Component and the stream silently carries an EMPTY token —
328
+ # which makes any non-self-rendering reply (reply.streams #30, reply.append /
329
+ # reply.remove #35) add-once-only: the first action works, then the stale (here
330
+ # empty) token is rejected on the next dispatch (cosmos#1939). A bare Streamable
331
+ # has no reactive_token method at all, so it still returns false correctly.
236
332
  def to_stream_token
237
- token = respond_to?(:reactive_token) ? reactive_token : nil
333
+ token = respond_to?(:reactive_token, true) ? reactive_token : nil
238
334
  %(<turbo-stream action="reactive:token" target="#{ERB::Util.html_escape(id)}" data-reactive-token-value="#{ERB::Util.html_escape(token)}"></turbo-stream>)
239
335
  end
240
336
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -87,17 +87,69 @@ module Phlex
87
87
  attr_writer :flash_target
88
88
 
89
89
  # Render a Phlex component to HTML with a full (off-request) view context.
90
+ # Uses phlex-rails' #render_in against the memoized view context — a direct
91
+ # component.call that skips ActionController's renderer.render machinery
92
+ # (~2x faster, ~half the allocations), with the same HTML and full helper
93
+ # access (dom_id/url_for/t/csrf). Used for a Phlex component embedded as
94
+ # Response#with content.
90
95
  def render(component)
91
- renderer.render(component, layout: false)
96
+ component.render_in(off_request_view_context)
92
97
  end
93
98
 
94
99
  # A Turbo::Streams::TagBuilder bound to an off-request view context, used
95
100
  # to build standalone streams (e.g. a Response flash append) not tied to a
96
- # specific component's id.
101
+ # specific component's id. Cached PER THREAD alongside the context it's
102
+ # bound to (see off_request_view_context for why per-thread).
97
103
  def flash_builder
98
- ::Turbo::Streams::TagBuilder.new(renderer.new.view_context)
104
+ off_request_view_context_cache[:builder]
99
105
  end
100
106
 
107
+ # The off-request view context for the current thread, built once and
108
+ # reused for both the flash builder and standalone component renders.
109
+ # Cached PER THREAD, not per process: an ActionView context carries mutable
110
+ # output_buffer/view_flow state (render_in's capture swaps it), so sharing
111
+ # one instance across threads can interleave content on a threaded server.
112
+ # Rebuilt when the renderer object changes or the generation is bumped
113
+ # (reset_flash_builder! / Rails code reload), so a reloaded controller is
114
+ # never served stale.
115
+ def off_request_view_context
116
+ off_request_view_context_cache[:view_context]
117
+ end
118
+
119
+ # Invalidate the per-thread context + builder for ALL threads by bumping
120
+ # the generation; each thread rebuilds lazily on next use. Registered on
121
+ # Rails' reloader by the engine; also used by specs. Thread-safe (an
122
+ # integer bump, no shared structure to tear down).
123
+ def reset_flash_builder!
124
+ @off_request_view_context_generation = off_request_view_context_generation + 1
125
+ end
126
+
127
+ def off_request_view_context_generation
128
+ @off_request_view_context_generation ||= 0
129
+ end
130
+
131
+ private
132
+
133
+ def off_request_view_context_cache
134
+ cache = Thread.current[:phlex_reactive_off_request_view_context]
135
+ current = renderer
136
+ generation = off_request_view_context_generation
137
+
138
+ unless cache && cache[:renderer].equal?(current) && cache[:generation] == generation
139
+ view_context = current.new.view_context
140
+ cache = {
141
+ view_context: view_context,
142
+ builder: ::Turbo::Streams::TagBuilder.new(view_context),
143
+ renderer: current,
144
+ generation: generation
145
+ }
146
+ Thread.current[:phlex_reactive_off_request_view_context] = cache
147
+ end
148
+ cache
149
+ end
150
+
151
+ public
152
+
101
153
  def base_controller_name
102
154
  @base_controller_name ||= "ActionController::Base"
103
155
  end
@@ -166,7 +218,7 @@ module Phlex
166
218
  logger.warn(
167
219
  "[phlex-reactive] POST #{path} does not resolve to #{ACTIONS_CONTROLLER}. " \
168
220
  "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 " \
221
+ "actions will 404. Exempt #{path.delete_prefix("/")} from the catch-all, or set " \
170
222
  "Phlex::Reactive.action_path to an unshadowed path. See the README integration section."
171
223
  )
172
224
  end
@@ -225,4 +277,4 @@ loader.ignore("#{lib}/generators")
225
277
  loader.do_not_eager_load("#{__dir__}/reactive/engine.rb")
226
278
  loader.setup
227
279
 
228
- require "phlex/reactive/engine" if defined?(::Rails::Engine)
280
+ 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.0
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
  - - ">="