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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +179 -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 +107 -11
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +57 -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,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
|
|
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
|
|
@@ -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
|
|
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
|
|
@@ -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
|
- - ">="
|