phlex-reactive 0.2.9 → 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,30 +78,68 @@ 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
- def replace(model = nil, **options)
136
+ # `morph: true` emits `<turbo-stream action="replace" method="morph">` so
137
+ # Turbo 8's bundled Idiomorph morphs the subtree in place — preserving a
138
+ # focused <input> + caret — instead of an outerHTML swap (issue #28).
139
+ # Default (morph: false) is the unchanged plain replace.
140
+ def replace(model = nil, morph: false, **options)
68
141
  component = build(model, options)
69
- turbo_stream_builder.replace(component.id, html: render_component(component))
142
+ turbo_stream_builder.replace(component.id, html: render_component(component), **morph_method(morph))
70
143
  end
71
144
 
72
145
  def update(model = nil, **options)
@@ -103,11 +176,15 @@ module Phlex
103
176
  # connection id — pass it to suppress the actor's own echo (the actor
104
177
  # already got the action's HTTP response). With Action Cable these are
105
178
  # ignored; with pgbus they reach the dispatcher. See docs/broadcasting.
106
- def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
179
+ # `morph: true` makes the live cross-tab update morph in place (issue #28),
180
+ # so a peer tab keeps its focus/caret on the morphed row. The broadcast
181
+ # path takes EXTRA <turbo-stream> attributes via `attributes:` (not the
182
+ # TagBuilder's `method:` kwarg), so the morph flag rides there.
183
+ def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
107
184
  component = build(model, options)
108
185
  ::Turbo::StreamsChannel.broadcast_replace_to(
109
186
  *streamables, target: component.id, html: render_component(component),
110
- **broadcast_transport_opts(exclude:, visible_to:)
187
+ **morph_attributes(morph), **broadcast_transport_opts(exclude:, visible_to:)
111
188
  )
112
189
  end
113
190
 
@@ -149,6 +226,20 @@ module Phlex
149
226
  new(**(model ? component_args(model, options) : options))
150
227
  end
151
228
 
229
+ # The TagBuilder (the .replace class builder + to_stream_morph) takes
230
+ # `method: :morph` to emit the `method="morph"` attribute. Pass it ONLY
231
+ # when morphing, so the default call produces today's plain replace.
232
+ def morph_method(morph)
233
+ morph ? { method: :morph } : {}
234
+ end
235
+
236
+ # The BROADCAST path renders extra <turbo-stream> attributes through
237
+ # `attributes:` (it has no `method:` kwarg — that would fall into the
238
+ # render args and be dropped). Same wire result: method="morph".
239
+ def morph_attributes(morph)
240
+ morph ? { attributes: { method: "morph" } } : {}
241
+ end
242
+
152
243
  # Only include transport opts that were actually given, so on Action
153
244
  # Cable (which doesn't accept them) the common no-opts call is unchanged.
154
245
  def broadcast_transport_opts(exclude:, visible_to:)
@@ -161,6 +252,25 @@ module Phlex
161
252
  def renderer
162
253
  Phlex::Reactive.renderer
163
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
164
274
  end
165
275
 
166
276
  # Required: the stable DOM id used as the Turbo Stream target. It MUST
@@ -184,10 +294,46 @@ module Phlex
184
294
  self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self))
185
295
  end
186
296
 
297
+ # Render THIS instance as a MORPHING replace (issue #28):
298
+ # `<turbo-stream action="replace" method="morph">`. Turbo 8's bundled
299
+ # Idiomorph morphs the subtree in place — preserving the focused <input> +
300
+ # caret across the re-render — while still carrying the root's fresh
301
+ # data-reactive-token-value (so the signed token refreshes). Used by
302
+ # Response.morph / Response.replace(self, morph: true).
303
+ def to_stream_morph
304
+ self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self), method: :morph)
305
+ end
306
+
187
307
  def to_stream_update
188
308
  self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
189
309
  end
190
310
 
311
+ # Render a TOKEN-ONLY refresh stream (issue #30): a tiny
312
+ # `<turbo-stream action="reactive:token">` carrying the component's fresh
313
+ # signed token, with NO rendered body. It lets an action update only PART
314
+ # of a component (its own hand-built streams) while still rolling the
315
+ # signed identity token forward — the client reads the next token from this
316
+ # attribute (#extractToken) and an inert client action writes it onto the
317
+ # root (a pure attribute set, so a focused <input> + caret survive). Unlike
318
+ # to_stream_replace, it does NOT re-render the children, so a live input
319
+ # the user is typing into is never torn down. Used by Response.streams.
320
+ #
321
+ # The component carries its token via Component#reactive_token; a Streamable
322
+ # that isn't a Component (no token) simply has nothing to refresh — guarded
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.
332
+ def to_stream_token
333
+ token = respond_to?(:reactive_token, true) ? reactive_token : nil
334
+ %(<turbo-stream action="reactive:token" target="#{ERB::Util.html_escape(id)}" data-reactive-token-value="#{ERB::Util.html_escape(token)}"></turbo-stream>)
335
+ end
336
+
191
337
  # Render THIS instance as a remove stream. The component already knows its
192
338
  # own #id, so no record/class reconstruction is needed (works for record-
193
339
  # and state-backed components alike). Used by Response.remove.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.2.9"
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.2.9
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -129,6 +129,7 @@ files:
129
129
  - lib/phlex/reactive.rb
130
130
  - lib/phlex/reactive/component.rb
131
131
  - lib/phlex/reactive/engine.rb
132
+ - lib/phlex/reactive/reply.rb
132
133
  - lib/phlex/reactive/response.rb
133
134
  - lib/phlex/reactive/streamable.rb
134
135
  - lib/phlex/reactive/version.rb
@@ -147,7 +148,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
147
148
  requirements:
148
149
  - - ">="
149
150
  - !ruby/object:Gem::Version
150
- version: 3.2.0
151
+ version: 3.4.0
151
152
  required_rubygems_version: !ruby/object:Gem::Requirement
152
153
  requirements:
153
154
  - - ">="