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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +200 -0
- data/README.md +111 -3
- data/app/controllers/phlex/reactive/actions_controller.rb +49 -12
- data/app/javascript/phlex/reactive/reactive_controller.js +113 -17
- data/lib/generators/phlex/reactive/install/install_generator.rb +6 -6
- data/lib/phlex/reactive/component.rb +97 -19
- data/lib/phlex/reactive/engine.rb +18 -9
- data/lib/phlex/reactive/reply.rb +30 -3
- data/lib/phlex/reactive/response.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +110 -11
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +81 -5
- metadata +2 -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,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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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
|
-
|
|
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
|
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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?(
|
|
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.
|
|
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.
|
|
151
|
+
version: 3.4.0
|
|
152
152
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
153
153
|
requirements:
|
|
154
154
|
- - ">="
|