phlex-reactive 0.16.0 → 0.16.2

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +282 -145
  3. data/README.md +85 -1
  4. data/app/controllers/phlex/reactive/actions_controller.rb +10 -0
  5. data/app/javascript/phlex/reactive/core.js +1 -0
  6. data/app/javascript/phlex/reactive/core.min.js +4 -4
  7. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  8. data/app/javascript/phlex/reactive/features/bindings.js +81 -4
  9. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  10. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  11. data/app/javascript/phlex/reactive/features/drag.js +522 -0
  12. data/app/javascript/phlex/reactive/features/drag.min.js +4 -0
  13. data/app/javascript/phlex/reactive/features/drag.min.js.map +10 -0
  14. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  15. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  16. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +7 -6
  17. data/app/javascript/phlex/reactive/runtime.js +57 -23
  18. data/lib/phlex/reactive/component/css_vars_binding.rb +118 -0
  19. data/lib/phlex/reactive/component/drag.rb +270 -0
  20. data/lib/phlex/reactive/component/dsl.rb +3 -0
  21. data/lib/phlex/reactive/component/helpers.rb +5 -1
  22. data/lib/phlex/reactive/component/registry.rb +15 -5
  23. data/lib/phlex/reactive/engine.rb +14 -5
  24. data/lib/phlex/reactive/held_fields.rb +21 -1
  25. data/lib/phlex/reactive/param_schema.rb +5 -1
  26. data/lib/phlex/reactive/response.rb +55 -4
  27. data/lib/phlex/reactive/shareable.rb +18 -3
  28. data/lib/phlex/reactive/test_helpers.rb +6 -0
  29. data/lib/phlex/reactive/version.rb +1 -1
  30. data/sig/phlex/reactive/response.rbs +3 -0
  31. metadata +6 -1
@@ -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)))
@@ -20,10 +20,12 @@ module Phlex
20
20
  # build script (scripts/build_client.js), the core's own table and the
21
21
  # default entry name the same features —
22
22
  # spec/phlex/engine_client_pin_spec.rb fails when they disagree.
23
- CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence countdown effects hints devtools].freeze
24
- # (Gzipped, bun zlib level 9, KB = 1,024 B: the default file 30.9 KB; the core 14.3 KB;
25
- # persist 3.1, defer 2.9, form 1.1, bindings 6.5, compute 2.0, sortable
26
- # 3.1, presence 1.3, countdown 0.9, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
23
+ CLIENT_FEATURES = %w[
24
+ persist defer form bindings compute sortable drag presence countdown effects hints devtools
25
+ ].freeze
26
+ # (Gzipped, bun zlib level 9, KB = 1,024 B: the default file 39.5 KB; the core 17.9 KB;
27
+ # persist 3.2, defer 2.9, form 1.2, bindings 8.0, compute 2.0, sortable
28
+ # 3.1, drag 3.5, presence 1.3, countdown 0.9, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
27
29
  # is the source of truth and holds a ratchet on each.)
28
30
 
29
31
  # Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
@@ -32,13 +34,20 @@ module Phlex
32
34
  # (the privately cacheable lazy render, issue #277). Apps can change the
33
35
  # paths with Phlex::Reactive.action_path / .defer_path / .fragment_path
34
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.
35
42
  initializer "phlex_reactive.routes" do
36
- it.routes.append do
43
+ routes = proc do
37
44
  post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
38
45
  post Phlex::Reactive.defer_path, to: "phlex/reactive/actions#deferred", as: :phlex_reactive_defer
39
46
  get "#{Phlex::Reactive.fragment_path}/:id", to: "phlex/reactive/fragments#show",
40
47
  as: :phlex_reactive_fragment
41
48
  end
49
+ routes = ::Ractor.shareable_proc(&routes) if defined?(::Ractor) && ::Ractor.respond_to?(:shareable_proc)
50
+ it.routes.append(&routes)
42
51
  end
43
52
 
44
53
  # Make the MINIFIED client build available to Propshaft/Sprockets so it can
@@ -14,9 +14,15 @@ module Phlex
14
14
  #
15
15
  # def save(title: nil)
16
16
  # authorize!(@note, to: :update?)
17
- # return conflict! if reactive_held_fields.include?("title") && title != @note.title
17
+ # return reply.morph.refused("title") if reactive_held_fields.include?("title") && title != @note.title
18
18
  # ...
19
19
  #
20
+ # A reply to the root's own request releases its marks: it answers exactly
21
+ # those values. A refusal (`reply.morph.refused`, issue #413) is sent back
22
+ # in the X-Reactive-Refused header, and the client keeps the marks it names,
23
+ # so the user's next keystroke is refused too until the action stores the
24
+ # field or the user takes the other value.
25
+ #
20
26
  # The header is UNTRUSTED input, like every request field: it carries names
21
27
  # only, never values, and is read as data — capped in bytes, count and
22
28
  # length, non-strings dropped, and filtered to the names the running action
@@ -26,6 +32,8 @@ module Phlex
26
32
  # It is a hint about what the user saw, not authorization.
27
33
  module HeldFields
28
34
  HEADER = "X-Reactive-Held"
35
+ # The response header a refusing reply sends (issue #413).
36
+ REFUSED_HEADER = "X-Reactive-Refused"
29
37
 
30
38
  # A raw header longer than this is ignored unparsed.
31
39
  MAX_BYTES = 4_096
@@ -62,6 +70,18 @@ module Phlex
62
70
  EMPTY
63
71
  end
64
72
 
73
+ # The X-Reactive-Refused value for Response#refused_fields: "*" keeps
74
+ # every mark, a JSON list keeps those names. ASCII only (a header is a
75
+ # byte string), as the client escapes X-Reactive-Held. A list past
76
+ # MAX_BYTES (a header a proxy may refuse) widens to "*": keeping more
77
+ # marks than named is safe, losing the refusal is not.
78
+ def refused_header(fields)
79
+ return "*" if fields == :all
80
+
81
+ json = JSON.generate(fields, ascii_only: true)
82
+ json.bytesize > MAX_BYTES ? "*" : json
83
+ end
84
+
65
85
  # Keep the strings among `names` that `action_def` declares as params.
66
86
  def filter(names, action_def)
67
87
  declared = declared_names(action_def)
@@ -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
@@ -19,6 +19,7 @@ module Phlex
19
19
  # reply.remove # drop the element (e.g. moderation queue)
20
20
  # reply.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
21
21
  # reply.replace.stream(Totals.update(@order)) # multi-stream
22
+ # reply.morph.refused("title") # answered, not stored: keep the field held
22
23
  class Response
23
24
  attr_reader :streams, :redirect_url, :token_component, :subject_component
24
25
 
@@ -351,8 +352,11 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
351
352
  # reply never allocates an empty array per Response.
352
353
  NO_SEGMENTS = [].freeze
353
354
 
355
+ # refused: what #refused recorded (issue #413) — nil, :all, or a frozen
356
+ # list of field names.
354
357
  def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil,
355
- subject_component: nil, deferred_segments: NO_SEGMENTS, pending_segments: NO_SEGMENTS)
358
+ subject_component: nil, deferred_segments: NO_SEGMENTS, pending_segments: NO_SEGMENTS,
359
+ refused: nil)
356
360
  @streams = streams.freeze
357
361
  @redirect_url = redirect_url
358
362
  @render_self = render_self
@@ -360,9 +364,14 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
360
364
  @subject_component = subject_component
361
365
  @deferred_segments = deferred_segments.freeze
362
366
  @pending_segments = pending_segments.freeze
367
+ @refused_fields = refused
363
368
  freeze
364
369
  end
365
370
 
371
+ # What the reply refused (issue #413): :all, the field names, or nil when
372
+ # it refused nothing.
373
+ attr_reader :refused_fields
374
+
366
375
  # The recorded reply.defer segments (issue #165), in call order. The
367
376
  # Response only RECORDS them; the endpoint (via the Defer module) builds
368
377
  # the placeholder + directive streams AFTER the action's transaction
@@ -388,7 +397,8 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
388
397
  token_component: @token_component,
389
398
  subject_component: @subject_component,
390
399
  deferred_segments: @deferred_segments,
391
- pending_segments: @pending_segments
400
+ pending_segments: @pending_segments,
401
+ refused: @refused_fields
392
402
  )
393
403
  end
394
404
 
@@ -420,7 +430,8 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
420
430
  subject_component: @subject_component,
421
431
  deferred_segments: @deferred_segments +
422
432
  [Phlex::Reactive::Defer::Segment.new(component:, placeholder:, morph:)],
423
- pending_segments: @pending_segments
433
+ pending_segments: @pending_segments,
434
+ refused: @refused_fields
424
435
  )
425
436
  end
426
437
 
@@ -464,10 +475,50 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
464
475
  token_component: @token_component || built.token_component,
465
476
  subject_component: @subject_component,
466
477
  deferred_segments: @deferred_segments,
467
- pending_segments: @pending_segments + built.pending_segments
478
+ pending_segments: @pending_segments + built.pending_segments,
479
+ refused: @refused_fields
480
+ )
481
+ end
482
+
483
+ # Answer the posted values without storing them (issue #413). Every reply
484
+ # to a root's own request releases the fields a morph held (issue #370),
485
+ # because it answers exactly those values. A reply that refused them says
486
+ # so, and the client keeps the marks: the root's next action names the
487
+ # same fields in X-Reactive-Held again, instead of saving over the other
488
+ # change on the user's next keystroke.
489
+ #
490
+ # if reactive_held_fields.include?("title") && title != @note.title
491
+ # @conflict = title
492
+ # return reply.morph.refused("title")
493
+ # end
494
+ #
495
+ # No names keeps every mark the root holds; names (the fields' `name`
496
+ # attributes, as reactive_held_fields reports them) keep only those, and
497
+ # the root's other marks release as usual. Nothing else about the reply
498
+ # changes: it is a 200, with no error marker and no reactive:error.
499
+ # Returns a NEW Response (immutable). Dead on a redirect, like #defer.
500
+ def refused(*names)
501
+ if redirect?
502
+ raise Phlex::Reactive::Error,
503
+ "reply.refused on a redirect reply is dead — the client is navigating away, " \
504
+ "so there is no held field left to keep"
505
+ end
506
+
507
+ names = names.flatten.map { -it.to_s }.uniq.freeze
508
+ self.class.new(
509
+ streams: @streams,
510
+ redirect_url: @redirect_url,
511
+ render_self: @render_self,
512
+ token_component: @token_component,
513
+ subject_component: @subject_component,
514
+ deferred_segments: @deferred_segments,
515
+ pending_segments: @pending_segments,
516
+ refused: names.empty? ? :all : names
468
517
  )
469
518
  end
470
519
 
520
+ def refused? = !@refused_fields.nil?
521
+
471
522
  # Chain a `reactive:js` op stream — server-pushed client DOM ops (issue
472
523
  # #97) — onto this reply, so a focus/dispatch/class toggle runs on the
473
524
  # client after the render applies:
@@ -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
@@ -271,6 +271,12 @@ module Phlex
271
271
  @response&.redirect_url
272
272
  end
273
273
 
274
+ # The action answered without storing (reply.morph.refused, issue #413):
275
+ # the client keeps the fields it held.
276
+ def refused?
277
+ !@response.nil? && @response.refused?
278
+ end
279
+
274
280
  # The actor's turbo-stream strings, exactly as the endpoint would render
275
281
  # them (response_streams) — a legacy return is the single self-replace; a
276
282
  # Response is honored, with the same token-refresh streams appended.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.16.0"
5
+ VERSION = "0.16.2"
6
6
  end
7
7
  end
@@ -9,6 +9,7 @@ module Phlex
9
9
  attr_reader subject_component: untyped
10
10
  attr_reader deferred_segments: Array[untyped]
11
11
  attr_reader pending_segments: Array[untyped]
12
+ attr_reader refused_fields: (:all | Array[String])?
12
13
 
13
14
  # --- chaining ---
14
15
  def stream: (*String more) -> Response
@@ -17,6 +18,7 @@ module Phlex
17
18
  def also: (?untyped companion, ?morph: bool, **untyped targets) -> Response
18
19
  def defer: (untyped component, ?placeholder: untyped, ?morph: bool) -> Response
19
20
  def pending: (untyped records, ?peers: bool, ?job: untyped, ?args: untyped, **untyped opts) ?{ (?) -> untyped } -> Response
21
+ def refused: (*(String | Symbol | Array[String | Symbol]) names) -> Response
20
22
 
21
23
  # Removed: each raises a guided error naming its replacement.
22
24
  def also_update: (untyped target, **untyped) -> bot
@@ -28,6 +30,7 @@ module Phlex
28
30
  def refresh_token?: () -> bool
29
31
  def deferred?: () -> bool
30
32
  def pending?: () -> bool
33
+ def refused?: () -> bool
31
34
 
32
35
  # --- the builders `reply` delegates to ---
33
36
  def self.build_replace: (untyped component, ?morph: bool, ?effect: untyped) -> Response
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.0
4
+ version: 0.16.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -149,6 +149,9 @@ files:
149
149
  - app/javascript/phlex/reactive/features/devtools.js
150
150
  - app/javascript/phlex/reactive/features/devtools.min.js
151
151
  - app/javascript/phlex/reactive/features/devtools.min.js.map
152
+ - app/javascript/phlex/reactive/features/drag.js
153
+ - app/javascript/phlex/reactive/features/drag.min.js
154
+ - app/javascript/phlex/reactive/features/drag.min.js.map
152
155
  - app/javascript/phlex/reactive/features/effects.js
153
156
  - app/javascript/phlex/reactive/features/effects.min.js
154
157
  - app/javascript/phlex/reactive/features/effects.min.js.map
@@ -197,6 +200,8 @@ files:
197
200
  - lib/phlex/reactive/collections.rb
198
201
  - lib/phlex/reactive/component.rb
199
202
  - lib/phlex/reactive/component/countdown_binding.rb
203
+ - lib/phlex/reactive/component/css_vars_binding.rb
204
+ - lib/phlex/reactive/component/drag.rb
200
205
  - lib/phlex/reactive/component/dsl.rb
201
206
  - lib/phlex/reactive/component/editable.rb
202
207
  - lib/phlex/reactive/component/field_references.rb