phlex-reactive 0.16.1 → 0.16.3
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 +58 -1
- data/README.md +33 -4
- data/app/javascript/phlex/reactive/features/bindings.js +37 -7
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/inspect.js +4 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +3 -0
- data/lib/phlex/reactive/component/helpers.rb +32 -1
- data/lib/phlex/reactive/component/identity.rb +1 -1
- data/lib/phlex/reactive/component/registry.rb +15 -5
- data/lib/phlex/reactive/engine.rb +8 -1
- data/lib/phlex/reactive/param_schema.rb +5 -1
- data/lib/phlex/reactive/shareable.rb +18 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -311,6 +311,9 @@ module Phlex
|
|
|
311
311
|
|
|
312
312
|
# Issue #184: params: :symbol resolves a registered named schema.
|
|
313
313
|
params = Phlex::Reactive.param_schema(params) if params.is_a?(Symbol)
|
|
314
|
+
# A deep-frozen copy, shared with the compiled schema below: the
|
|
315
|
+
# definition stays readable from a non-main Ractor once warmed.
|
|
316
|
+
params = Phlex::Reactive::Shareable.share(params)
|
|
314
317
|
# If a scope is already declared, reject a schema nested under the scope
|
|
315
318
|
# key here (the scope-first declaration order). The action-first order is
|
|
316
319
|
# caught in reactive_scope.
|
|
@@ -741,7 +741,18 @@ module Phlex
|
|
|
741
741
|
# exactly one of field/input: per call — the field form stays the
|
|
742
742
|
# blessed default:
|
|
743
743
|
# reactive_filter(input: "#user_tags_query")
|
|
744
|
-
|
|
744
|
+
#
|
|
745
|
+
# Issue #425: `match:` picks where the query must sit in the haystack —
|
|
746
|
+
# :substring (anywhere, the default), :prefix (at its start) or
|
|
747
|
+
# :word_start (at its start, or right after whitespace or one of
|
|
748
|
+
# - ' ( ) , . /). A long alphabetical list (countries, cities, people)
|
|
749
|
+
# wants :word_start, so "n" keeps Nantes and New York, not every name
|
|
750
|
+
# with an n in it. `fold: true` folds diacritics on BOTH sides before
|
|
751
|
+
# comparing, so "zur" finds Zürich and "zür" still does. The defaults
|
|
752
|
+
# emit nothing, so an existing filter's wire is byte-identical:
|
|
753
|
+
# reactive_filter(:q, match: :word_start, fold: true)
|
|
754
|
+
def reactive_filter(field = nil, input: nil, option: nil, group: nil, empty: nil,
|
|
755
|
+
match: :substring, fold: false)
|
|
745
756
|
# nil-presence, NOT truthiness: `input: cond && "#sel"` with cond false
|
|
746
757
|
# must fail loudly in filter_selector! below (a boolean is never a
|
|
747
758
|
# selector), never slip into the field branch and emit a dead binding.
|
|
@@ -768,6 +779,10 @@ module Phlex
|
|
|
768
779
|
# byte-stable wire and always-emit an attribute the client would then query).
|
|
769
780
|
data[:reactive_filter_group] = filter_selector!(:group, group) if group
|
|
770
781
|
data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
|
|
782
|
+
# match:/fold: too (issue #425): the defaults emit nothing.
|
|
783
|
+
mode = filter_match!(match)
|
|
784
|
+
data[:reactive_filter_match] = mode.to_s unless mode == :substring
|
|
785
|
+
data[:reactive_filter_fold] = "true" if fold
|
|
771
786
|
|
|
772
787
|
{ data: }
|
|
773
788
|
end
|
|
@@ -1427,8 +1442,24 @@ module Phlex
|
|
|
1427
1442
|
LEGACY_SHOW_PREDICATE_KEYS = %i[equals not in gte gt lte lt].freeze
|
|
1428
1443
|
LEGACY_SHOW_CONNECTIVE_KEYS = %i[all any].freeze
|
|
1429
1444
|
|
|
1445
|
+
# reactive_filter's match: modes (issue #425), in the order the error names them.
|
|
1446
|
+
FILTER_MATCH_MODES = %i[substring prefix word_start].freeze
|
|
1447
|
+
private_constant :FILTER_MATCH_MODES
|
|
1448
|
+
|
|
1430
1449
|
private
|
|
1431
1450
|
|
|
1451
|
+
# reactive_filter's match: (issue #425), a Symbol or a String naming one
|
|
1452
|
+
# of FILTER_MATCH_MODES (the reactive_persist storage: precedent).
|
|
1453
|
+
# Anything else raises at render: the client would fall back to
|
|
1454
|
+
# substring matching and the list would narrow the wrong way.
|
|
1455
|
+
def filter_match!(match)
|
|
1456
|
+
mode = match.to_sym if match.is_a?(Symbol) || match.is_a?(String)
|
|
1457
|
+
return mode if FILTER_MATCH_MODES.include?(mode)
|
|
1458
|
+
|
|
1459
|
+
raise ArgumentError,
|
|
1460
|
+
"reactive_filter match: must be one of #{FILTER_MATCH_MODES.join(", ")}, got #{match.inspect}"
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1432
1463
|
# A reactive_filter/reactive_listnav selector, validated non-blank and
|
|
1433
1464
|
# stringified (issue #163). A blank selector is a dead binding — the
|
|
1434
1465
|
# client would silently match nothing — so it fails loudly at render,
|
|
@@ -10,7 +10,7 @@ module Phlex
|
|
|
10
10
|
#
|
|
11
11
|
# THE token hot path. Both memos below are bare defined?/||= fast paths
|
|
12
12
|
# ON PURPOSE: a render must never pay a per-read generation compare
|
|
13
|
-
# (issue #115 invariant, .
|
|
13
|
+
# (issue #115 invariant, AGENTS.md → Performance). Coherence comes
|
|
14
14
|
# from the WRITE side instead — Component::Registry.bump! sweeps these
|
|
15
15
|
# memos off the declaring class and all its descendants whenever any
|
|
16
16
|
# registry declaration lands (class-load-shaped, rare), so they can't go
|
|
@@ -103,6 +103,13 @@ module Phlex
|
|
|
103
103
|
# attribute fragments), shared by warm! so a non-main Ractor can read it.
|
|
104
104
|
MEMO_IVARS = %i[@reactive_record_ivar @reactive_state_ivars @reactive_effect_attrs @reactive_on_complete_attr].freeze
|
|
105
105
|
|
|
106
|
+
# Every class ivar warm! leaves for a request to read, each shared
|
|
107
|
+
# deeply so a non-main Ractor can read it: the class's own
|
|
108
|
+
# declarations, the resolution cache and the render path's memos.
|
|
109
|
+
# (The generation counters next to them are Integers, shareable as
|
|
110
|
+
# they are.)
|
|
111
|
+
WARMED_IVARS = [*OWN_IVARS.values, :@reactive_registry_cache, *MEMO_IVARS].freeze
|
|
112
|
+
|
|
106
113
|
# Every class with the reactive DSL, a ClientBindings-only view
|
|
107
114
|
# included (Streamable's registry has only the token-bearing ones):
|
|
108
115
|
# what the engine warms at boot. Per-Ractor (Phlex::Reactive::Store);
|
|
@@ -161,7 +168,7 @@ module Phlex
|
|
|
161
168
|
|
|
162
169
|
# Resolve every registry kind and build the render path's per-class
|
|
163
170
|
# memos for each reactive component class among `classes`, then
|
|
164
|
-
# freeze each class
|
|
171
|
+
# share (deep-freeze) what each class keeps (WARMED_IVARS). The engine runs it at boot
|
|
165
172
|
# when the app eager-loads (every class is loaded by then), so a
|
|
166
173
|
# request reads memos and never writes the class. A declaration
|
|
167
174
|
# after it (a development reload) bumps the generation and the next
|
|
@@ -226,10 +233,14 @@ module Phlex
|
|
|
226
233
|
end
|
|
227
234
|
|
|
228
235
|
# The class's own storage for `kind`, initialized from the block on
|
|
229
|
-
# first write.
|
|
236
|
+
# first write. warm! shares (freezes) it, so a declaration after
|
|
237
|
+
# warm! (a development reload) writes to a fresh copy.
|
|
230
238
|
def own!(klass, kind)
|
|
231
239
|
ivar = OWN_IVARS.fetch(kind)
|
|
232
|
-
|
|
240
|
+
current = klass.instance_variable_get(ivar)
|
|
241
|
+
return current if current && !current.frozen?
|
|
242
|
+
|
|
243
|
+
klass.instance_variable_set(ivar, current ? current.dup : yield)
|
|
233
244
|
end
|
|
234
245
|
|
|
235
246
|
# Invalidate every resolution memo in the process (generation bump)
|
|
@@ -257,8 +268,7 @@ module Phlex
|
|
|
257
268
|
|
|
258
269
|
def warm_class(klass)
|
|
259
270
|
[*READERS.values, *MEMO_READERS].each { klass.public_send(it) if klass.respond_to?(it) }
|
|
260
|
-
|
|
261
|
-
MEMO_IVARS.each do
|
|
271
|
+
WARMED_IVARS.each do
|
|
262
272
|
next unless klass.instance_variable_defined?(it)
|
|
263
273
|
|
|
264
274
|
klass.instance_variable_set(it, Phlex::Reactive::Shareable.share(klass.instance_variable_get(it)))
|
|
@@ -34,13 +34,20 @@ module Phlex
|
|
|
34
34
|
# (the privately cacheable lazy render, issue #277). Apps can change the
|
|
35
35
|
# paths with Phlex::Reactive.action_path / .defer_path / .fragment_path
|
|
36
36
|
# before boot.
|
|
37
|
+
#
|
|
38
|
+
# Rails main's RouteSet#finalize! makes the route set Ractor-shareable,
|
|
39
|
+
# append blocks included, and a block's self here is the engine, which
|
|
40
|
+
# no Ractor can share. Ractor.shareable_proc (Ruby 4.0+) gives the block
|
|
41
|
+
# a nil self; Mapper evaluates it with its own self either way.
|
|
37
42
|
initializer "phlex_reactive.routes" do
|
|
38
|
-
|
|
43
|
+
routes = proc do
|
|
39
44
|
post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
|
|
40
45
|
post Phlex::Reactive.defer_path, to: "phlex/reactive/actions#deferred", as: :phlex_reactive_defer
|
|
41
46
|
get "#{Phlex::Reactive.fragment_path}/:id", to: "phlex/reactive/fragments#show",
|
|
42
47
|
as: :phlex_reactive_fragment
|
|
43
48
|
end
|
|
49
|
+
routes = ::Ractor.shareable_proc(&routes) if defined?(::Ractor) && ::Ractor.respond_to?(:shareable_proc)
|
|
50
|
+
it.routes.append(&routes)
|
|
44
51
|
end
|
|
45
52
|
|
|
46
53
|
# Make the MINIFIED client build available to Propshaft/Sprockets so it can
|
|
@@ -143,8 +143,12 @@ module Phlex
|
|
|
143
143
|
|
|
144
144
|
attr_reader :schema
|
|
145
145
|
|
|
146
|
+
# Ractor-shareable once built: a deep-frozen copy of the declared schema
|
|
147
|
+
# (the app's own Hash is left alone) and nothing written after this, so
|
|
148
|
+
# a warmed action definition can be read from a non-main Ractor.
|
|
146
149
|
def initialize(schema)
|
|
147
|
-
@schema = schema
|
|
150
|
+
@schema = Phlex::Reactive::Shareable.share(schema)
|
|
151
|
+
freeze
|
|
148
152
|
end
|
|
149
153
|
|
|
150
154
|
# Coerce client params against this schema. Anything not in the schema is
|
|
@@ -23,6 +23,10 @@ module Phlex
|
|
|
23
23
|
# works on the main Ractor only;
|
|
24
24
|
# * a Hash keeps its default value or default proc and its
|
|
25
25
|
# compare_by_identity;
|
|
26
|
+
# * a Data value (the gem's declarations: an action, a compute, a
|
|
27
|
+
# collection) comes back as a copy with every member shared, when
|
|
28
|
+
# its class keeps Data's own initialize (one of its own may
|
|
29
|
+
# normalize or validate, and is never run again);
|
|
26
30
|
# * any other object (a MessageVerifier, an APM adapter, an app object)
|
|
27
31
|
# is kept as is.
|
|
28
32
|
#
|
|
@@ -35,6 +39,7 @@ module Phlex
|
|
|
35
39
|
when ::Proc then share_proc(value.dup) || value
|
|
36
40
|
when ::Array then seal(value.map { share(it) })
|
|
37
41
|
when ::Hash then share_hash(value)
|
|
42
|
+
when ::Data then plain_data?(value) ? share_data(value) : value
|
|
38
43
|
when ::String then value.dup.freeze
|
|
39
44
|
else value
|
|
40
45
|
end
|
|
@@ -60,12 +65,14 @@ module Phlex
|
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
# The Proc made shareable in place, or nil when it cannot be (or there
|
|
63
|
-
# is no Ractor).
|
|
68
|
+
# is no Ractor). Ruby raises Ractor::IsolationError for an unshareable
|
|
69
|
+
# self or capture, and ArgumentError for a Proc that assigns a variable
|
|
70
|
+
# it captures.
|
|
64
71
|
def share_proc(callable)
|
|
65
72
|
return unless defined?(::Ractor)
|
|
66
73
|
|
|
67
74
|
::Ractor.make_shareable(callable)
|
|
68
|
-
rescue ::Ractor::Error
|
|
75
|
+
rescue ::Ractor::Error, ::ArgumentError
|
|
69
76
|
nil
|
|
70
77
|
end
|
|
71
78
|
|
|
@@ -78,10 +85,18 @@ module Phlex
|
|
|
78
85
|
seal(copy)
|
|
79
86
|
end
|
|
80
87
|
|
|
88
|
+
# Data#with runs the class's initialize again: only Data's own, which
|
|
89
|
+
# just assigns the members, is safe to.
|
|
90
|
+
def plain_data?(data) = data.class.instance_method(:initialize).owner.equal?(::Data)
|
|
91
|
+
|
|
92
|
+
# A copy with every member shared. Data is always frozen, so the copy
|
|
93
|
+
# is shareable when every member is.
|
|
94
|
+
def share_data(data) = data.with(**data.to_h.transform_values { share(it) })
|
|
95
|
+
|
|
81
96
|
# Frozen; shareable as well when everything inside it is.
|
|
82
97
|
def seal(collection) = collection.freeze
|
|
83
98
|
|
|
84
|
-
private_class_method :share_proc, :share_hash, :seal
|
|
99
|
+
private_class_method :share_proc, :share_hash, :plain_data?, :share_data, :seal
|
|
85
100
|
end
|
|
86
101
|
end
|
|
87
102
|
end
|