reactive_component 0.1.0 → 0.6.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.
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/concern"
3
+ require 'active_support/concern'
4
4
 
5
- require_relative "reactive_component/version"
6
- require_relative "reactive_component/compiler"
7
- require_relative "reactive_component/erb_extractor"
8
- require_relative "reactive_component/data_evaluator"
9
- require_relative "reactive_component/wrapper"
10
- require_relative "reactive_component/engine" if defined?(Rails::Engine)
5
+ require_relative 'reactive_component/version'
6
+ require_relative 'reactive_component/compiler'
7
+ require_relative 'reactive_component/data_evaluator'
8
+ require_relative 'reactive_component/wrapper'
9
+ require_relative 'reactive_component/broadcastable'
10
+ require_relative 'reactive_component/engine' if defined?(Rails::Engine)
11
11
 
12
12
  module ReactiveComponent
13
13
  extend ActiveSupport::Concern
@@ -17,15 +17,22 @@ module ReactiveComponent
17
17
 
18
18
  class Error < StandardError; end
19
19
 
20
+ # A template the compiler cannot make reactive — e.g. a loop variable read in
21
+ # a way the client could never satisfy. Raised on first compile (lazily, on
22
+ # first render or from a test that touches compiled_template_js), never in a
23
+ # browser.
24
+ class CompileError < Error; end
25
+
20
26
  included do
21
27
  class_attribute :_live_model_attr, instance_writer: false
22
28
  class_attribute :_live_model_class_name, instance_writer: false
23
29
  class_attribute :_live_actions, instance_writer: false, default: {}
24
30
  class_attribute :_broadcast_config, instance_writer: false
25
31
  class_attribute :_client_state_fields, instance_writer: false, default: {}
32
+ class_attribute :_subscribed_events, instance_writer: false, default: %i[create update destroy]
26
33
  end
27
34
 
28
- def render_in(view_context, &block)
35
+ def render_in(view_context, &)
29
36
  inner_html = super
30
37
  return inner_html unless self.class._live_model_attr
31
38
  return inner_html if @_skip_live_wrapper
@@ -36,26 +43,126 @@ module ReactiveComponent
36
43
  stream = ReactiveComponent::Wrapper.find_stream_for(self.class, record)
37
44
 
38
45
  client_state = if self.class._client_state_fields.any?
39
- kwargs = {}
40
- self.class._client_state_fields.each_key do |name|
41
- val = instance_variable_get(:"@#{name}")
42
- kwargs[name] = val unless val.nil?
43
- end
44
- self.class.client_state_values(**kwargs)
45
- end
46
+ kwargs = {}
47
+ self.class._client_state_fields.each_key do |name|
48
+ val = instance_variable_get(:"@#{name}")
49
+ kwargs[name] = val unless val.nil?
50
+ end
51
+ self.class.client_state_values(**kwargs)
52
+ end
46
53
 
47
54
  extra_opts = respond_to?(:live_wrapper_options, true) ? live_wrapper_options : {}
48
55
 
49
- wrapped = ReactiveComponent::Wrapper.wrap(self.class, record, inner_html, stream: stream, client_state: client_state, **extra_opts)
56
+ wrapped = ReactiveComponent::Wrapper.wrap(self.class, record, inner_html, stream: stream, client_state: client_state,
57
+ **extra_opts)
50
58
 
51
59
  template_script = self.class.template_script_tag(view_context)
52
60
  template_script ? (template_script + wrapped).html_safe : wrapped
53
61
  end
54
62
 
63
+ # Raised when an extracted template expression returns something that is
64
+ # not a primitive — e.g. a full ActiveRecord record, a custom object, or
65
+ # a Date/Time. Broadcast payloads are JSON-serialized and visible to every
66
+ # connected client, so letting a record through would leak every column
67
+ # (including `password_digest` and tokens). Rather than silently coerce,
68
+ # we raise so the developer fixes the template.
69
+ class UnsafeBroadcastValueError < StandardError; end
70
+
71
+ # Types that are safe to ship verbatim in a broadcast payload. Everything
72
+ # else must be converted in the template (e.g. `@user.name` instead of
73
+ # `@user`, `@date.iso8601` instead of `@date`).
74
+ SAFE_PRIMITIVE_TYPES = [NilClass, TrueClass, FalseClass, Integer, Float, String].freeze
75
+
76
+ def self.sanitize_for_broadcast(value, source: nil)
77
+ return value if value.nil? || value.is_a?(TrueClass) || value.is_a?(FalseClass)
78
+ return value if value.is_a?(Integer) || value.is_a?(Float) || value.is_a?(String)
79
+ return value.to_s if value.is_a?(Symbol)
80
+ return value.map { |v| sanitize_for_broadcast(v, source: source) } if value.is_a?(Array)
81
+
82
+ if value.is_a?(Hash)
83
+ return value.each_with_object({}) do |(k, v), h|
84
+ key = k.is_a?(Symbol) ? k.to_s : k
85
+ raise_unsafe!(k, source, context: 'Hash key') unless key.is_a?(String) || key.is_a?(Integer)
86
+ h[key] = sanitize_for_broadcast(v, source: source)
87
+ end
88
+ end
89
+
90
+ raise_unsafe!(value, source)
91
+ end
92
+
93
+ def self.raise_unsafe!(value, source, context: 'Extracted expression')
94
+ label = source ? "`#{source}`" : 'an extracted expression'
95
+ hint =
96
+ if defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
97
+ "Narrow the ERB to a specific column (e.g. `#{source || '@record'}.name`) — " \
98
+ 'shipping the record would leak every column over ActionCable.'
99
+ elsif value.is_a?(Time) || value.is_a?(Date) || (defined?(ActiveSupport::TimeWithZone) && value.is_a?(ActiveSupport::TimeWithZone))
100
+ "Call a formatter in the template (e.g. `#{source}.iso8601` or `time_ago_in_words(#{source})`)."
101
+ else
102
+ 'Convert the value to a primitive in the template ' \
103
+ '(String, Integer, Float, Boolean, nil, Symbol, or Array/Hash of those) before outputting it.'
104
+ end
105
+
106
+ raise UnsafeBroadcastValueError,
107
+ "[ReactiveComponent] #{context} #{label} returned a #{value.class.name}, which is not safe to broadcast. #{hint}"
108
+ end
109
+ private_class_method :raise_unsafe!
110
+
111
+ def self.broadcast_for(component_class, record, action:)
112
+ return unless component_class._subscribed_events.include?(action)
113
+
114
+ config = component_class._broadcast_config
115
+ stream = if config&.dig(:stream)
116
+ s = config[:stream]
117
+ s.is_a?(Proc) ? s.call(record) : s
118
+ else
119
+ record
120
+ end
121
+
122
+ case action
123
+ when :update
124
+ Channel.broadcast_data(stream, action: :update, data: component_class.build_data(record))
125
+ when :destroy
126
+ Channel.broadcast_data(stream, action: :destroy, data: {
127
+ 'id' => record.id, 'dom_id' => component_class.dom_id_for(record)
128
+ })
129
+ when :create
130
+ target = config&.dig(:prepend_target)
131
+ return unless target
132
+ return unless (renderer = ReactiveComponent.renderer)
133
+
134
+ html = renderer.render(component_class.new(component_class.live_model_attr => record), layout: false)
135
+ Turbo::StreamsChannel.broadcast_prepend_to(*Array(stream), target: target, html: html)
136
+ end
137
+ end
138
+
55
139
  class_methods do
56
- def subscribes_to(attr_name, class_name: nil)
140
+ def subscribes_to(attr_name, class_name: nil, only: %i[create update destroy])
57
141
  self._live_model_attr = attr_name.to_sym
58
142
  self._live_model_class_name = class_name || attr_name.to_s.classify
143
+ self._subscribed_events = Array(only).map(&:to_sym)
144
+
145
+ component_class = self
146
+
147
+ # Wire up the model to broadcast when it changes.
148
+ # We need to handle two cases:
149
+ # 1. ActiveRecord isn't loaded yet - use on_load hook
150
+ # 2. ActiveRecord is already loaded - wire up immediately
151
+ wire_up_model = lambda do
152
+ model_class = component_class.live_model_class
153
+ return unless model_class
154
+
155
+ model_class.include(ReactiveComponent::Broadcastable)
156
+ model_class.register_reactive_component(component_class)
157
+ rescue NameError
158
+ # model class not yet defined — wiring skipped
159
+ end
160
+
161
+ if defined?(ActiveRecord::Base)
162
+ wire_up_model.call
163
+ else
164
+ ActiveSupport.on_load(:active_record) { wire_up_model.call }
165
+ end
59
166
  end
60
167
 
61
168
  def live_model_class
@@ -125,10 +232,10 @@ module ReactiveComponent
125
232
 
126
233
  def encoded_template
127
234
  @encoded_template ||= if ReactiveComponent.debug
128
- compiled_template_js
129
- else
130
- Base64.strict_encode64(compiled_template_js)
131
- end
235
+ compiled_template_js
236
+ else
237
+ Base64.strict_encode64(compiled_template_js)
238
+ end
132
239
  end
133
240
 
134
241
  def template_element_id
@@ -136,7 +243,7 @@ module ReactiveComponent
136
243
  end
137
244
 
138
245
  def template_script_tag(view_context)
139
- emitted = (view_context.instance_variable_get(:@_reactive_component_templates) || Set.new)
246
+ emitted = view_context.instance_variable_get(:@_reactive_component_templates) || Set.new
140
247
  return nil if emitted.include?(name)
141
248
 
142
249
  emitted.add(name)
@@ -144,12 +251,26 @@ module ReactiveComponent
144
251
  %(<script type="text/x-template" id="#{template_element_id}">#{encoded_template}</script>).html_safe
145
252
  end
146
253
 
254
+ # The wrapper id, and the `dom_id` the client matches broadcasts against.
255
+ #
256
+ # Always prefixed with the component, never the bare `dom_id(record)`: two
257
+ # components rendering the same record would otherwise share one id, and
258
+ # since routing is by id each would render the OTHER's payload — a
259
+ # TypeError deep in the compiled template the moment their shapes differ,
260
+ # plus duplicate ids in the page. `dom_id_prefix` still overrides the
261
+ # default when a shorter or hand-picked id is wanted.
147
262
  def dom_id_for(record)
148
- if respond_to?(:dom_id_prefix) && dom_id_prefix.present?
149
- ActionView::RecordIdentifier.dom_id(record, dom_id_prefix)
150
- else
151
- ActionView::RecordIdentifier.dom_id(record)
152
- end
263
+ ActionView::RecordIdentifier.dom_id(record, dom_id_prefix.presence || default_dom_id_prefix)
264
+ end
265
+
266
+ def dom_id_prefix = nil
267
+
268
+ # MessageRowComponent → message_row; Admin::MessageRowComponent → admin_message_row.
269
+ # An anonymous class (tests) has no name to distinguish it by.
270
+ def default_dom_id_prefix
271
+ return 'component' unless name
272
+
273
+ name.underscore.tr('/', '_').delete_suffix('_component')
153
274
  end
154
275
 
155
276
  def expression_field_map
@@ -163,10 +284,10 @@ module ReactiveComponent
163
284
 
164
285
  compiled_data[:expressions].each do |var_name, ruby_source|
165
286
  data[var_name] = if collection_computed.key?(var_name)
166
- evaluator.evaluate_collection(ruby_source, collection_computed[var_name])
167
- else
168
- evaluator.evaluate(ruby_source)
169
- end
287
+ evaluator.evaluate_collection(ruby_source, collection_computed[var_name])
288
+ else
289
+ evaluator.evaluate(ruby_source)
290
+ end
170
291
  end
171
292
  compiled_data[:simple_ivars].each do |ivar_name|
172
293
  data[ivar_name] = kwargs[ivar_name.to_sym] if kwargs.key?(ivar_name.to_sym)
@@ -180,32 +301,44 @@ module ReactiveComponent
180
301
  collection_computed = compiled_data[:collection_computed] || {}
181
302
 
182
303
  compiled_data[:expressions].each do |var_name, ruby_source|
183
- data[var_name] = if collection_computed.key?(var_name)
184
- evaluator.evaluate_collection(ruby_source, collection_computed[var_name])
185
- else
186
- evaluator.evaluate(ruby_source)
187
- end
304
+ value = if collection_computed.key?(var_name)
305
+ evaluator.evaluate_collection(ruby_source, collection_computed[var_name])
306
+ else
307
+ evaluator.evaluate(ruby_source)
308
+ end
309
+ data[var_name] = ReactiveComponent.sanitize_for_broadcast(value, source: ruby_source)
188
310
  end
189
311
 
190
312
  compiled_data[:simple_ivars].each do |ivar_name|
191
- data[ivar_name] = kwargs[ivar_name.to_sym] if kwargs.key?(ivar_name.to_sym)
313
+ # The live-model ivar itself (e.g. `@message` when `subscribes_to :message`)
314
+ # is the subscription key, not a payload field. It's only in the ivar list
315
+ # because extract_ivar_names sees `@message.subject` etc. — the emitter never
316
+ # emits the bare name in the destructure for extracted chains, so don't
317
+ # ship it and don't sanitize-raise on it.
318
+ next if live_model_attr && ivar_name == live_model_attr.to_s
319
+
320
+ value = kwargs.key?(ivar_name.to_sym) ? kwargs[ivar_name.to_sym] : evaluator.evaluate("@#{ivar_name}")
321
+ data[ivar_name] = ReactiveComponent.sanitize_for_broadcast(value, source: "@#{ivar_name}")
192
322
  end
193
323
 
194
324
  (compiled_data[:nested_components] || {}).each do |key, info|
195
325
  klass = info[:class_name].constantize
196
326
  kwargs_values = {}
197
327
  info[:kwargs].each do |kwarg_name, ruby_source|
328
+ # Nested-component kwargs keep full objects — the child evaluator
329
+ # needs them to compute its own extracted fields. Only the fields
330
+ # that actually ship to the client go through sanitize_for_broadcast.
198
331
  kwargs_values[kwarg_name.to_sym] = evaluator.evaluate(ruby_source)
199
332
  end
200
333
  data[key] = if klass.respond_to?(:build_data_for_nested)
201
- klass.build_data_for_nested(**kwargs_values)
202
- else
203
- ReactiveComponent::Compiler.build_data_for_nested(klass, **kwargs_values)
204
- end
334
+ klass.build_data_for_nested(**kwargs_values)
335
+ else
336
+ ReactiveComponent::Compiler.build_data_for_nested(klass, **kwargs_values)
337
+ end
205
338
  end
206
339
 
207
- data["id"] = record.id
208
- data["dom_id"] = dom_id_for(record)
340
+ data['id'] = record.id
341
+ data['dom_id'] = dom_id_for(record)
209
342
  data
210
343
  end
211
344
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reactive_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Przemyslaw Lusar
@@ -10,81 +10,87 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: view_component
13
+ name: erubi
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '3.0'
18
+ version: '1.11'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '3.0'
25
+ version: '1.11'
26
26
  - !ruby/object:Gem::Dependency
27
- name: turbo-rails
27
+ name: prism
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '2.0'
32
+ version: '1.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '2.0'
39
+ version: '1.0'
40
40
  - !ruby/object:Gem::Dependency
41
- name: ruby2js
41
+ name: rails
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - "~>"
44
+ - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '5.1'
46
+ version: '7.1'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '9'
47
50
  type: :runtime
48
51
  prerelease: false
49
52
  version_requirements: !ruby/object:Gem::Requirement
50
53
  requirements:
51
- - - "~>"
54
+ - - ">="
52
55
  - !ruby/object:Gem::Version
53
- version: '5.1'
56
+ version: '7.1'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '9'
54
60
  - !ruby/object:Gem::Dependency
55
- name: prism
61
+ name: turbo-rails
56
62
  requirement: !ruby/object:Gem::Requirement
57
63
  requirements:
58
64
  - - "~>"
59
65
  - !ruby/object:Gem::Version
60
- version: '1.0'
66
+ version: '2.0'
61
67
  type: :runtime
62
68
  prerelease: false
63
69
  version_requirements: !ruby/object:Gem::Requirement
64
70
  requirements:
65
71
  - - "~>"
66
72
  - !ruby/object:Gem::Version
67
- version: '1.0'
73
+ version: '2.0'
68
74
  - !ruby/object:Gem::Dependency
69
- name: rails
75
+ name: view_component
70
76
  requirement: !ruby/object:Gem::Requirement
71
77
  requirements:
72
78
  - - ">="
73
79
  - !ruby/object:Gem::Version
74
- version: '7.1'
80
+ version: '3.0'
75
81
  - - "<"
76
82
  - !ruby/object:Gem::Version
77
- version: '9'
83
+ version: '5'
78
84
  type: :runtime
79
85
  prerelease: false
80
86
  version_requirements: !ruby/object:Gem::Requirement
81
87
  requirements:
82
88
  - - ">="
83
89
  - !ruby/object:Gem::Version
84
- version: '7.1'
90
+ version: '3.0'
85
91
  - - "<"
86
92
  - !ruby/object:Gem::Version
87
- version: '9'
93
+ version: '5'
88
94
  description: Build reactive, real-time UI components that automatically re-render
89
95
  server-side when subscribed models change. Uses ViewComponent, Turbo Streams, and
90
96
  ActionCable to keep your UI in sync without writing custom JavaScript.
@@ -103,19 +109,23 @@ files:
103
109
  - config/importmap.rb
104
110
  - config/routes.rb
105
111
  - lib/reactive_component.rb
112
+ - lib/reactive_component/broadcastable.rb
106
113
  - lib/reactive_component/compiler.rb
107
114
  - lib/reactive_component/data_evaluator.rb
108
115
  - lib/reactive_component/engine.rb
109
- - lib/reactive_component/erb_extractor.rb
116
+ - lib/reactive_component/erubi.rb
117
+ - lib/reactive_component/transpiler.rb
110
118
  - lib/reactive_component/version.rb
111
119
  - lib/reactive_component/wrapper.rb
112
- homepage: https://github.com/przymusiala/reactive_component
120
+ homepage: https://lluzak.github.io/reactive_component/
113
121
  licenses:
114
122
  - MIT
115
123
  metadata:
116
- homepage_uri: https://github.com/przymusiala/reactive_component
117
- source_code_uri: https://github.com/przymusiala/reactive_component
118
- changelog_uri: https://github.com/przymusiala/reactive_component/blob/main/CHANGELOG.md
124
+ homepage_uri: https://lluzak.github.io/reactive_component/
125
+ documentation_uri: https://lluzak.github.io/reactive_component/
126
+ source_code_uri: https://github.com/lluzak/reactive_component
127
+ changelog_uri: https://github.com/lluzak/reactive_component/blob/main/CHANGELOG.md
128
+ bug_tracker_uri: https://github.com/lluzak/reactive_component/issues
119
129
  rubygems_mfa_required: 'true'
120
130
  rdoc_options: []
121
131
  require_paths: