reactive_component 0.1.0 → 0.7.0

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
@@ -85,9 +192,12 @@ module ReactiveComponent
85
192
  end
86
193
  end
87
194
 
195
+ # `params:` takes the same spec as `ActionController::Parameters#permit`:
196
+ # live_action :move, params: [:label_id]
197
+ # live_action :save, params: [:title, { tags: [], address: [:city] }]
88
198
  def live_action(action_name, params: [])
89
199
  self._live_actions = _live_actions.merge(
90
- action_name.to_sym => { params: Array(params).map(&:to_sym) }
200
+ action_name.to_sym => { params: Array(params) }
91
201
  )
92
202
  end
93
203
 
@@ -106,13 +216,11 @@ module ReactiveComponent
106
216
  instance = allocate
107
217
  instance.instance_variable_set(:"@#{live_model_attr}", record)
108
218
 
109
- allowed = action_config[:params]
110
- if allowed.any?
111
- filtered = action_params.symbolize_keys.slice(*allowed)
112
- instance.send(action_name, **filtered)
113
- else
114
- instance.send(action_name)
115
- end
219
+ # `permit` (not `permit!` + slice): only declared keys, and only scalar
220
+ # values, so a nested hash or array never reaches the action method.
221
+ action_params = ActionController::Parameters.new(action_params) unless action_params.is_a?(ActionController::Parameters)
222
+ filtered = action_params.permit(*action_config[:params]).to_h.symbolize_keys
223
+ instance.send(action_name, **filtered)
116
224
  end
117
225
 
118
226
  def compiled_data
@@ -125,10 +233,10 @@ module ReactiveComponent
125
233
 
126
234
  def encoded_template
127
235
  @encoded_template ||= if ReactiveComponent.debug
128
- compiled_template_js
129
- else
130
- Base64.strict_encode64(compiled_template_js)
131
- end
236
+ compiled_template_js
237
+ else
238
+ Base64.strict_encode64(compiled_template_js)
239
+ end
132
240
  end
133
241
 
134
242
  def template_element_id
@@ -136,7 +244,7 @@ module ReactiveComponent
136
244
  end
137
245
 
138
246
  def template_script_tag(view_context)
139
- emitted = (view_context.instance_variable_get(:@_reactive_component_templates) || Set.new)
247
+ emitted = view_context.instance_variable_get(:@_reactive_component_templates) || Set.new
140
248
  return nil if emitted.include?(name)
141
249
 
142
250
  emitted.add(name)
@@ -144,12 +252,26 @@ module ReactiveComponent
144
252
  %(<script type="text/x-template" id="#{template_element_id}">#{encoded_template}</script>).html_safe
145
253
  end
146
254
 
255
+ # The wrapper id, and the `dom_id` the client matches broadcasts against.
256
+ #
257
+ # Always prefixed with the component, never the bare `dom_id(record)`: two
258
+ # components rendering the same record would otherwise share one id, and
259
+ # since routing is by id each would render the OTHER's payload — a
260
+ # TypeError deep in the compiled template the moment their shapes differ,
261
+ # plus duplicate ids in the page. `dom_id_prefix` still overrides the
262
+ # default when a shorter or hand-picked id is wanted.
147
263
  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
264
+ ActionView::RecordIdentifier.dom_id(record, dom_id_prefix.presence || default_dom_id_prefix)
265
+ end
266
+
267
+ def dom_id_prefix = nil
268
+
269
+ # MessageRowComponent → message_row; Admin::MessageRowComponent → admin_message_row.
270
+ # An anonymous class (tests) has no name to distinguish it by.
271
+ def default_dom_id_prefix
272
+ return 'component' unless name
273
+
274
+ name.underscore.tr('/', '_').delete_suffix('_component')
153
275
  end
154
276
 
155
277
  def expression_field_map
@@ -163,10 +285,10 @@ module ReactiveComponent
163
285
 
164
286
  compiled_data[:expressions].each do |var_name, ruby_source|
165
287
  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
288
+ evaluator.evaluate_collection(ruby_source, collection_computed[var_name])
289
+ else
290
+ evaluator.evaluate(ruby_source)
291
+ end
170
292
  end
171
293
  compiled_data[:simple_ivars].each do |ivar_name|
172
294
  data[ivar_name] = kwargs[ivar_name.to_sym] if kwargs.key?(ivar_name.to_sym)
@@ -180,32 +302,44 @@ module ReactiveComponent
180
302
  collection_computed = compiled_data[:collection_computed] || {}
181
303
 
182
304
  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
305
+ value = if collection_computed.key?(var_name)
306
+ evaluator.evaluate_collection(ruby_source, collection_computed[var_name])
307
+ else
308
+ evaluator.evaluate(ruby_source)
309
+ end
310
+ data[var_name] = ReactiveComponent.sanitize_for_broadcast(value, source: ruby_source)
188
311
  end
189
312
 
190
313
  compiled_data[:simple_ivars].each do |ivar_name|
191
- data[ivar_name] = kwargs[ivar_name.to_sym] if kwargs.key?(ivar_name.to_sym)
314
+ # The live-model ivar itself (e.g. `@message` when `subscribes_to :message`)
315
+ # is the subscription key, not a payload field. It's only in the ivar list
316
+ # because extract_ivar_names sees `@message.subject` etc. — the emitter never
317
+ # emits the bare name in the destructure for extracted chains, so don't
318
+ # ship it and don't sanitize-raise on it.
319
+ next if live_model_attr && ivar_name == live_model_attr.to_s
320
+
321
+ value = kwargs.key?(ivar_name.to_sym) ? kwargs[ivar_name.to_sym] : evaluator.evaluate("@#{ivar_name}")
322
+ data[ivar_name] = ReactiveComponent.sanitize_for_broadcast(value, source: "@#{ivar_name}")
192
323
  end
193
324
 
194
325
  (compiled_data[:nested_components] || {}).each do |key, info|
195
326
  klass = info[:class_name].constantize
196
327
  kwargs_values = {}
197
328
  info[:kwargs].each do |kwarg_name, ruby_source|
329
+ # Nested-component kwargs keep full objects — the child evaluator
330
+ # needs them to compute its own extracted fields. Only the fields
331
+ # that actually ship to the client go through sanitize_for_broadcast.
198
332
  kwargs_values[kwarg_name.to_sym] = evaluator.evaluate(ruby_source)
199
333
  end
200
334
  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
335
+ klass.build_data_for_nested(**kwargs_values)
336
+ else
337
+ ReactiveComponent::Compiler.build_data_for_nested(klass, **kwargs_values)
338
+ end
205
339
  end
206
340
 
207
- data["id"] = record.id
208
- data["dom_id"] = dom_id_for(record)
341
+ data['id'] = record.id
342
+ data['dom_id'] = dom_id_for(record)
209
343
  data
210
344
  end
211
345
 
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.7.0
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: