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.
@@ -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
- def reactive_filter(field = nil, input: nil, option: nil, group: nil, empty: nil)
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, .claude/rules/performance.md). Coherence comes
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's resolution cache. The engine runs it at boot
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
- klass.instance_variable_get(ivar) || klass.instance_variable_set(ivar, yield)
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
- klass.instance_variable_get(:@reactive_registry_cache)&.freeze
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
- it.routes.append do
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.16.1"
5
+ VERSION = "0.16.3"
6
6
  end
7
7
  end
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.16.1
4
+ version: 0.16.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson