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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +220 -0
- data/README.md +161 -30
- data/app/controllers/phlex/reactive/actions_controller.rb +72 -11
- data/app/javascript/phlex/reactive/reactive_controller.js +150 -22
- data/lib/generators/phlex/reactive/install/install_generator.rb +6 -6
- data/lib/phlex/reactive/component.rb +117 -21
- data/lib/phlex/reactive/engine.rb +18 -9
- data/lib/phlex/reactive/reply.rb +102 -0
- data/lib/phlex/reactive/response.rb +146 -6
- data/lib/phlex/reactive/streamable.rb +158 -12
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +57 -5
- metadata +3 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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
|
-
|
|
133
|
+
component.render_in(turbo_view_context)
|
|
65
134
|
end
|
|
66
135
|
|
|
67
|
-
|
|
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
|
-
|
|
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.
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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?(
|
|
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.
|
|
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.
|
|
151
|
+
version: 3.4.0
|
|
151
152
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
152
153
|
requirements:
|
|
153
154
|
- - ">="
|