phlex-reactive 0.15.1 → 0.15.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +188 -0
  3. data/README.md +82 -9
  4. data/app/controllers/phlex/reactive/actions_controller.rb +4 -1
  5. data/app/javascript/phlex/reactive/compute.js +14 -1
  6. data/app/javascript/phlex/reactive/compute.min.js +2 -2
  7. data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
  8. data/app/javascript/phlex/reactive/core.min.js +4 -3
  9. data/app/javascript/phlex/reactive/core.min.js.map +3 -3
  10. data/app/javascript/phlex/reactive/features/bindings.js +66 -0
  11. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  12. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  13. data/app/javascript/phlex/reactive/features/form.js +54 -22
  14. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/features/persist.js +31 -2
  17. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  18. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  19. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -3
  20. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +6 -6
  21. data/app/javascript/phlex/reactive/runtime.js +476 -13
  22. data/lib/phlex/reactive/component/helpers.rb +24 -5
  23. data/lib/phlex/reactive/component/identity.rb +34 -1
  24. data/lib/phlex/reactive/component/image_fallback_binding.rb +54 -0
  25. data/lib/phlex/reactive/component/media_binding.rb +103 -0
  26. data/lib/phlex/reactive/component/media_pattern.rb +79 -0
  27. data/lib/phlex/reactive/doctor.rb +20 -8
  28. data/lib/phlex/reactive/held_fields.rb +87 -0
  29. data/lib/phlex/reactive/js.rb +153 -29
  30. data/lib/phlex/reactive/streamable.rb +26 -2
  31. data/lib/phlex/reactive/test_helpers.rb +11 -2
  32. data/lib/phlex/reactive/version.rb +1 -1
  33. metadata +5 -1
@@ -18,6 +18,16 @@ module Phlex
18
18
  module Identity
19
19
  extend ActiveSupport::Concern
20
20
 
21
+ # Whether the installed GlobalID gives a class its own app
22
+ # (global_id_app, then GlobalID.app — rails/globalid after 1.4).
23
+ # Read once: it is a fact about the loaded gem.
24
+ def self.per_class_gid_app?
25
+ return @per_class_gid_app if defined?(@per_class_gid_app)
26
+
27
+ @per_class_gid_app = ::GlobalID::Identification.const_defined?(:ClassMethods, false) &&
28
+ ::GlobalID::Identification::ClassMethods.method_defined?(:global_id_app)
29
+ end
30
+
21
31
  class_methods do
22
32
  # The record's instance-variable symbol (e.g. :@todo), computed once.
23
33
  # reactive_token reads it on every render; interpolating :"@#{key}" each
@@ -74,7 +84,7 @@ module Phlex
74
84
  # before to_gid (nil.to_gid would raise).
75
85
  if (record_ivar = klass.reactive_record_ivar) &&
76
86
  (record = instance_variable_get(record_ivar)) && signable_gid?(record)
77
- payload["gid"] = record.to_gid.to_s
87
+ payload["gid"] = reactive_gid(record)
78
88
  end
79
89
 
80
90
  state_ivars = klass.reactive_state_ivars
@@ -95,6 +105,29 @@ module Phlex
95
105
  def signable_gid?(record)
96
106
  !(record.respond_to?(:persisted?) && !record.persisted?)
97
107
  end
108
+
109
+ # record.to_gid.to_s, without the URI. For a record whose to_gid is
110
+ # GlobalID's own (not overridden on its class or on itself) and whose
111
+ # id is an Integer, GlobalID's string is exactly
112
+ # "gid://<GlobalID.app>/<class name>/<id>": an Integer needs no
113
+ # escaping and there are no params. Building it directly skips a
114
+ # validated URI::GID (~37 objects, about half a record token's cost),
115
+ # once per record-backed row per render. Anything else goes through
116
+ # to_gid unchanged, its errors included.
117
+ def reactive_gid(record)
118
+ if record.is_a?(::GlobalID::Identification) &&
119
+ record.method(:to_gid).owner.equal?(::GlobalID::Identification) &&
120
+ (id = record.id).is_a?(Integer) && (app = reactive_gid_app(record.class)) && (name = record.class.name)
121
+ "gid://#{app}/#{name}/#{id}"
122
+ else
123
+ record.to_gid.to_s
124
+ end
125
+ end
126
+
127
+ # The app to_gid would use: the class's own where GlobalID has one.
128
+ def reactive_gid_app(klass)
129
+ (Identity.per_class_gid_app? && klass.global_id_app) || ::GlobalID.app
130
+ end
98
131
  end
99
132
  end
100
133
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # An <img> that falls back when its src fails and recovers when the src
7
+ # changes (issue #383).
8
+ #
9
+ # img(id: "logo", alt: "", **reactive_image_fallback(hide: true))
10
+ # img(id: "logo", alt: "Bank", **reactive_image_fallback(show_alt: "#bank-name"))
11
+ # span(id: "bank-name", hidden: true)
12
+ #
13
+ # On `error` the client sets data-reactive-image-state="error", adds
14
+ # `hidden` (hide: true, the default) and, with show_alt:, writes the
15
+ # img's alt into that node (a CSS selector inside the root) and shows it.
16
+ # On `load` the state is "loaded". A later src — copy_media,
17
+ # reactive_media, preview_file, a morph, or any src that loads — clears
18
+ # the error and shows the img again; an img with no src is never an
19
+ # error. hide: false leaves visibility alone: style the state with CSS.
20
+ # Composes with reactive_media (its **attrs mix the data attribute in):
21
+ #
22
+ # img(**reactive_media(from: :rib, pool: POOL, **reactive_image_fallback))
23
+ #
24
+ # Named ImageFallbackBinding for the reason MediaBinding is (issue #233).
25
+ module ImageFallbackBinding
26
+ def reactive_image_fallback(hide: true, show_alt: nil, **attrs)
27
+ wire = { "hide" => image_fallback_hide!(hide) }
28
+ wire["alt"] = image_fallback_alt!(show_alt) unless show_alt.nil?
29
+ mix({ data: { reactive_image_fallback: wire.to_json } }, attrs)
30
+ end
31
+
32
+ private
33
+
34
+ def image_fallback_hide!(hide)
35
+ return hide if [true, false].include?(hide)
36
+
37
+ raise ArgumentError, "reactive_image_fallback hide: must be true or false, got #{hide.inspect}"
38
+ end
39
+
40
+ # A selector the client passes to querySelector, never a URL.
41
+ def image_fallback_alt!(selector)
42
+ if selector == JS::ROOT_SENTINEL
43
+ raise ArgumentError,
44
+ "reactive_image_fallback show_alt: #{selector} names the whole root — pass the selector of a node inside it"
45
+ end
46
+ return selector if selector.is_a?(String) && !selector.strip.empty?
47
+
48
+ raise ArgumentError,
49
+ "reactive_image_fallback show_alt: must be a CSS selector (e.g. \"#bank-name\"), got #{selector.inspect}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # A preview image that follows a field (issue #373), with no URL in the
7
+ # page's ops or attributes: the server renders every candidate <img>
8
+ # (hidden), and the binding copies the one whose key matches.
9
+ #
10
+ # input(name: "code")
11
+ # img(id: "logo", alt: "", **reactive_media(
12
+ # from: :code, take: 3,
13
+ # pool: { "007" => "#logo-007", "011" => "#logo-011" },
14
+ # label: "#caption", blank_label: "Unknown"
15
+ # ))
16
+ # img(id: "logo-007", src: "/logos/007.svg", alt: "North", hidden: true)
17
+ #
18
+ # The field is read the way reactive_show reads one (owned fields, a
19
+ # radio group's checked value, reactive_scope applied). `take:` keeps the
20
+ # first n characters as the key; omit it to use the whole value. A pool
21
+ # value is a CSS SELECTOR for an <img> inside the root, never a URL: the
22
+ # client copies that element's src, srcset and alt the way
23
+ # js.copy_media does. A key, selector or element that misses (or is not
24
+ # an <img>) clears the preview. A pool <img> with an alt and no src is a
25
+ # hit without an image: the preview drops its src and takes the alt
26
+ # (issue #381). `label:` names a node whose textContent becomes the
27
+ # chosen image's alt (`blank_label`, or "", on a miss). Client-only: no token,
28
+ # no round trip.
29
+ #
30
+ # Until the field has `take` characters, or while `match:` (a Regexp,
31
+ # anchored to the whole key) rejects the key, the binding is PENDING
32
+ # (issue #380): never `blank_label`. `pending: :clear` (the default)
33
+ # clears the preview and empties the label; `pending: :keep` leaves both
34
+ # alone. The preview's data-reactive-media-state reads pending, hit or
35
+ # miss, so a row can hide itself with CSS until there is an answer.
36
+ #
37
+ # Named MediaBinding, not Media: this module sits in every reactive
38
+ # component's ancestry, and a bare `Media` would shadow a host app's kit
39
+ # component of that name (issue #233).
40
+ module MediaBinding
41
+ def reactive_media(from:, pool: nil, take: nil, match: nil, pending: :clear, label: nil, blank_label: nil, **attrs)
42
+ wire = { "field" => media_field!(from) }
43
+ wire["take"] = media_take!(take) unless take.nil?
44
+ wire["match"] = MediaPattern.to_js(match) unless match.nil?
45
+ wire["pending"] = "keep" if media_pending!(pending) == "keep"
46
+ wire["pool"] = media_pool!(pool)
47
+ wire["label"] = media_selector!(:label, label) unless label.nil?
48
+ wire["blank"] = blank_label.to_s unless blank_label.nil?
49
+ mix({ data: { reactive_media: wire.to_json } }, attrs)
50
+ end
51
+
52
+ private
53
+
54
+ def media_field!(from)
55
+ name = from.to_s if from.is_a?(Symbol) || from.is_a?(String)
56
+ return name unless name.nil? || name.strip.empty?
57
+
58
+ raise ArgumentError, "reactive_media from: needs the field name to watch (e.g. from: :code), got #{from.inspect}"
59
+ end
60
+
61
+ def media_take!(take)
62
+ return take if take.is_a?(Integer) && take.positive?
63
+
64
+ raise ArgumentError, "reactive_media take: must be a positive Integer (or omitted for the whole value), " \
65
+ "got #{take.inspect}"
66
+ end
67
+
68
+ def media_pending!(pending)
69
+ mode = pending.to_s if pending.is_a?(Symbol) || pending.is_a?(String)
70
+ return mode if %w[clear keep].include?(mode)
71
+
72
+ raise ArgumentError, "reactive_media pending: must be :clear or :keep, got #{pending.inspect}"
73
+ end
74
+
75
+ def media_pool!(pool)
76
+ if pool.nil?
77
+ raise ArgumentError, "reactive_media needs pool: — a Hash of key => selector of a server-rendered <img> " \
78
+ "(e.g. pool: { \"007\" => \"#logo-007\" })"
79
+ end
80
+ unless pool.is_a?(Hash) && !pool.empty?
81
+ raise ArgumentError,
82
+ "reactive_media pool: must be a non-empty Hash of key => <img> selector, got #{pool.inspect}"
83
+ end
84
+
85
+ pool.to_h { |key, selector| [key.to_s, media_selector!(:pool, selector)] }
86
+ end
87
+
88
+ # A pool value or label: is a selector, never a URL — the client only
89
+ # ever passes it to querySelector.
90
+ def media_selector!(kind, selector)
91
+ if selector == JS::ROOT_SENTINEL
92
+ raise ArgumentError,
93
+ "reactive_media #{kind}: #{selector} names the whole root — pass the selector of an element inside it"
94
+ end
95
+ return selector if selector.is_a?(String) && !selector.strip.empty?
96
+
97
+ raise ArgumentError,
98
+ "reactive_media #{kind}: values are CSS selectors (e.g. \"#logo-007\"), got #{selector.inspect}"
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # reactive_media's match: (issue #380) — a Ruby Regexp as the JavaScript
7
+ # source the client anchors as ^(?:…)$ and compiles without flags. The
8
+ # source is tokenized (an escape is one token, so an escaped backslash is
9
+ # never mistaken for one): a leading \A and a trailing \z/\Z are dropped,
10
+ # and anything JavaScript would read differently raises at render rather
11
+ # than matching silently wrong in the browser.
12
+ module MediaPattern
13
+ # Escapes Ruby reads as an anchor or a class and JS reads as a letter,
14
+ # plus \s/\S, whose JS class also holds Unicode spaces Ruby's does not.
15
+ RUBY_ONLY_ESCAPES = %w[\A \z \Z \G \h \H \R \X \K \p \P \s \S].freeze
16
+ # The group openers both engines read alike: (?: (?= (?! (?<= (?<! (?<name>
17
+ SHARED_GROUPS = [":", "=", "!", "<"].freeze
18
+ OPTIONS = Regexp::IGNORECASE | Regexp::EXTENDED | Regexp::MULTILINE
19
+
20
+ module_function
21
+
22
+ def to_js(match)
23
+ unless match.is_a?(Regexp)
24
+ raise ArgumentError,
25
+ "reactive_media match: must be a Regexp (e.g. /\\A\\d{3}\\z/), got #{match.inspect}"
26
+ end
27
+
28
+ unless match.options.nobits?(OPTIONS)
29
+ refuse!(match,
30
+ "has options (i, m, x) the client would read differently — drop them")
31
+ end
32
+
33
+ tokens = match.source.scan(/\\.|./m)
34
+ tokens.shift if tokens.first == "\\A"
35
+ final = tokens.pop if %w[\z \Z].include?(tokens.last)
36
+ check!(tokens, match)
37
+ # Ruby's \Z also matches before one final newline.
38
+ final == "\\Z" ? "#{tokens.join}\\n?" : tokens.join
39
+ end
40
+
41
+ def check!(tokens, match)
42
+ in_class = false
43
+ tokens.each_with_index do |token, i|
44
+ if RUBY_ONLY_ESCAPES.include?(token)
45
+ refuse!(match,
46
+ "uses #{token}, which is Ruby-only (JavaScript reads it differently)")
47
+ end
48
+ refuse!(match, "has a character above U+FFFF (the client pattern has no u flag)") if token.ord > 0xFFFF
49
+ if token == "\\u" && tokens[i + 1] == "{"
50
+ refuse!(match,
51
+ "uses \\u{…}, which JavaScript without the u flag reads as a repeated u")
52
+ end
53
+ if in_class
54
+ in_class = false if token == "]"
55
+ class_syntax!(tokens, i, match)
56
+ elsif token == "["
57
+ in_class = true
58
+ elsif token == "(" && tokens[i + 1] == "?" && !SHARED_GROUPS.include?(tokens[i + 2])
59
+ refuse!(match, "uses (?#{tokens[i + 2]}…), a group JavaScript does not read the same way")
60
+ end
61
+ end
62
+ end
63
+
64
+ # A [ inside a class (a nested class or a POSIX [:digit:]) and && (an
65
+ # intersection) are Ruby-only: JavaScript reads them as literals.
66
+ def class_syntax!(tokens, index, match)
67
+ token = tokens[index]
68
+ return unless token == "[" || (token == "&" && tokens[index + 1] == "&")
69
+
70
+ refuse!(match, "has a nested character class, a POSIX [:class:] or && — JavaScript reads them literally")
71
+ end
72
+
73
+ def refuse!(match, why)
74
+ raise ArgumentError, "reactive_media match: #{match.inspect} #{why}"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -248,7 +248,15 @@ module Phlex
248
248
  # authorization / mark_authorized! call. It is :unknown, NEVER a hard fail:
249
249
  # a helper may authorize indirectly, so this is a hint, not a verdict.
250
250
  def authorization_check(components)
251
- suspects = components.flat_map { unauthorized_action_labels(it) }
251
+ infos = nil
252
+ suspects = components.flat_map do
253
+ next [] if component_defines_authorization_method?(it)
254
+
255
+ # Read once, and only when needed: Inspector.components Prism-scans
256
+ # every component's actions.
257
+ infos ||= inspector_infos
258
+ unauthorized_action_labels(it, infos)
259
+ end
252
260
  return Check.new(:ok, "every mutating action appears to authorize", name: :authorization) if suspects.empty?
253
261
 
254
262
  Check.new(:unknown, "actions with no detected authorization call: #{suspects.join(", ")}",
@@ -394,13 +402,11 @@ module Phlex
394
402
  end
395
403
 
396
404
  # "Klass#action" for every declared action on `klass` that (advisory)
397
- # appears unauthorized: the component defines no authorization method in its
398
- # ancestry, the action isn't skipped, and the Prism scan (via the shared
399
- # Inspector) finds no authorization / mark_authorized! call in its body.
400
- def unauthorized_action_labels(klass)
401
- return [] if component_defines_authorization_method?(klass)
402
-
403
- info = Phlex::Reactive::Inspector.components.find { it.klass == klass }
405
+ # appears unauthorized (the caller has already ruled out an authorization
406
+ # method in its ancestry): the action isn't skipped, and the Prism scan (via
407
+ # the shared Inspector) finds no authorization / mark_authorized! call in its body.
408
+ def unauthorized_action_labels(klass, infos)
409
+ info = infos[klass]
404
410
  return [] unless info
405
411
 
406
412
  info.actions
@@ -417,6 +423,12 @@ module Phlex
417
423
  # Phlex::Reactive.authorization_methods and NOT the Inspector's set, which
418
424
  # folds in mark_authorized! — every component inherits that instance helper,
419
425
  # so including it would silence the check for everything.
426
+ def inspector_infos
427
+ Phlex::Reactive::Inspector.components.to_h { [it.klass, it] }
428
+ rescue StandardError
429
+ {}
430
+ end
431
+
420
432
  def component_defines_authorization_method?(klass)
421
433
  Phlex::Reactive.authorization_methods.any? do
422
434
  klass.method_defined?(it) || klass.private_method_defined?(it)
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The fields a morph held over a changed server value (issue #370).
6
+ #
7
+ # Since #338 a morph keeps the value of the field being typed in and writes
8
+ # the render's value as the field's default. When that render differs from
9
+ # the old default, someone else changed the field while the user typed.
10
+ # The client marks such a field and the root's next action request names it
11
+ # in the X-Reactive-Held header (a JSON array of field names). Inside the
12
+ # action, `reactive_held_fields` returns those names, so the app can refuse
13
+ # or merge instead of silently overwriting the other change:
14
+ #
15
+ # def save(title: nil)
16
+ # authorize!(@note, to: :update?)
17
+ # return conflict! if reactive_held_fields.include?("title") && title != @note.title
18
+ # ...
19
+ #
20
+ # The header is UNTRUSTED input, like every request field: it carries names
21
+ # only, never values, and is read as data — capped in bytes, count and
22
+ # length, non-strings dropped, and filtered to the names the running action
23
+ # declares as params (matched on the base name, so `title[en]` counts for a
24
+ # declared `title`). A name the action can't receive can't be overwritten
25
+ # by it. Malformed input reads as nothing held; it never fails a request.
26
+ # It is a hint about what the user saw, not authorization.
27
+ module HeldFields
28
+ HEADER = "X-Reactive-Held"
29
+
30
+ # A raw header longer than this is ignored unparsed.
31
+ MAX_BYTES = 4_096
32
+ # At most this many names are read …
33
+ MAX_NAMES = 64
34
+ # … each at most this long.
35
+ MAX_NAME_LENGTH = 128
36
+
37
+ EMPTY = [].freeze
38
+
39
+ class << self
40
+ # The held field names during the current action ([] outside one).
41
+ def current
42
+ Thread.current[:phlex_reactive_held_fields] || EMPTY
43
+ end
44
+
45
+ # Expose `names` for the block, restoring the previous list after.
46
+ def with(names)
47
+ previous = Thread.current[:phlex_reactive_held_fields]
48
+ Thread.current[:phlex_reactive_held_fields] = names
49
+ yield
50
+ ensure
51
+ Thread.current[:phlex_reactive_held_fields] = previous
52
+ end
53
+
54
+ # The header's names that `action_def` declares, frozen; [] for
55
+ # anything absent, oversized or malformed.
56
+ def parse(raw, action_def)
57
+ return EMPTY if raw.nil? || raw.empty? || raw.bytesize > MAX_BYTES
58
+
59
+ names = JSON.parse(raw)
60
+ names.is_a?(Array) ? filter(names, action_def) : EMPTY
61
+ rescue JSON::ParserError
62
+ EMPTY
63
+ end
64
+
65
+ # Keep the strings among `names` that `action_def` declares as params.
66
+ def filter(names, action_def)
67
+ declared = declared_names(action_def)
68
+ return EMPTY if declared.empty?
69
+
70
+ names.first(MAX_NAMES).select do
71
+ it.is_a?(String) && it.length <= MAX_NAME_LENGTH && declared.include?(base_name(it))
72
+ end.uniq.freeze
73
+ end
74
+
75
+ private
76
+
77
+ def declared_names(action_def)
78
+ params = action_def.params
79
+ params.is_a?(Hash) ? params.keys.map(&:to_s) : EMPTY
80
+ end
81
+
82
+ # "title" for "title", "title[]" and "title[en]".
83
+ def base_name(name) = name.split("[", 2).first
84
+ end
85
+ end
86
+ end
87
+ end