reactive_component 0.1.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.
@@ -0,0 +1,610 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby2js"
4
+
5
+ module ReactiveComponent
6
+ module ErbExtractor
7
+ include Ruby2JS::Filter::SEXP
8
+
9
+ def initialize(*args)
10
+ super
11
+ @extracted_expressions = {}
12
+ @extracted_raw_fields = Set.new
13
+ @block_context_stack = []
14
+ @key_counter = 0
15
+ @source_to_key = {} # source string -> assigned key (scalar dedup)
16
+ end
17
+
18
+ def set_options(options)
19
+ super
20
+ @extraction_output = @options[:extraction]
21
+ @nestable_checker = @options[:nestable_checker]
22
+ @nested_counter = 0
23
+ end
24
+
25
+ # Intercept .each blocks to track block variable context.
26
+ # We override process() rather than on_block because the ERB filter
27
+ # sits above us in the MRO for on_block, so our override never gets
28
+ # called. By hooking process(), we push context BEFORE the normal
29
+ # handler chain (ERB -> Functions) runs, so process_erb_send_append
30
+ # sees the block context when processing the body.
31
+ def process(node)
32
+ return super unless node.respond_to?(:type)
33
+
34
+ # Pre-extract server-evaluable send nodes before other filters can
35
+ # transform them. The Functions filter processes certain patterns
36
+ # (e.g. respond_to? -> "in" operator) in its own process method,
37
+ # bypassing on_send where ErbExtractor normally does extraction.
38
+ if @erb_bufvar && node.type == :send && server_evaluable?(node) && !contains_lvar?(node)
39
+ unless in_block_context? && contains_block_var?(node)
40
+ if in_block_context? && current_block_context[:collection_source] == rebuild_source(node) && current_block_context[:collection_key].nil?
41
+ key = record_collection_extraction(node)
42
+ current_block_context[:collection_key] = key
43
+ else
44
+ key = record_extraction(node)
45
+ end
46
+ return s(:lvar, key.to_sym)
47
+ end
48
+ end
49
+
50
+ return super unless node.type == :block
51
+
52
+ call, args = node.children
53
+ return super unless call.type == :send
54
+
55
+ target, method = call.children
56
+ return super unless method == :each
57
+
58
+ block_var = args.children.first&.children&.first
59
+ return super unless block_var
60
+
61
+ collection_source = server_evaluable?(target) ? rebuild_source(target) : nil
62
+
63
+ @block_context_stack.push(
64
+ var: block_var,
65
+ computed: {},
66
+ collection_source: collection_source,
67
+ collection_key: nil
68
+ )
69
+
70
+ # For bare ivar/const targets (e.g., @labels.each, STATUS_FILTERS.each),
71
+ # the Functions filter converts the block to for...of without dispatching
72
+ # to on_send/on_ivar, so the collection is never extracted. Pre-extract
73
+ # here and rewrite the block node so the JS references the extracted variable.
74
+ if (target.type == :ivar || target.type == :const) && collection_source
75
+ key = record_collection_extraction(target)
76
+ current_block_context[:collection_key] = key
77
+ new_call = s(:send, s(:lvar, key.to_sym), :each)
78
+ node = s(:block, new_call, args, node.children[2])
79
+ end
80
+
81
+ result = super(node)
82
+ context = @block_context_stack.pop
83
+ flush_block_computed(context) if context[:collection_key]
84
+ result
85
+ end
86
+
87
+ # Hook called by ERB filter for expressions inside <%= %> tags.
88
+ # Despite the name, the node may be a :const (bare constant access).
89
+ def process_erb_send_append(send_node)
90
+ # Bare constant access (e.g., LabelBadgeComponent::COLORS)
91
+ if send_node.respond_to?(:type) && send_node.type == :const
92
+ key = record_extraction(send_node)
93
+ return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
94
+ s(:send, nil, :String, s(:lvar, key.to_sym)))
95
+ end
96
+
97
+ target, method, *args = send_node.children
98
+
99
+ # raw(expr) -- extract inner expression, mark as raw
100
+ if target.nil? && method == :raw && args.length == 1
101
+ inner = args.first
102
+ if extractable?(inner)
103
+ key = record_extraction(inner, raw: true)
104
+ return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, s(:lvar, key.to_sym))
105
+ end
106
+ return defined?(super) ? super : nil
107
+ end
108
+
109
+ # tag.span(content, class: "...") -- build tag in JS
110
+ if tag_builder?(target)
111
+ return process_tag_builder_append(send_node)
112
+ end
113
+
114
+ # Nestable component: compile to JS function instead of server-rendering HTML
115
+ if @nestable_checker && render_component_call?(send_node)
116
+ new_call = send_node.children[2]
117
+ const_node = new_call.children[0]
118
+ class_name = rebuild_source(const_node)
119
+ inside_block = in_block_context? && contains_block_var?(send_node)
120
+ klass = @nestable_checker.call(class_name, inside_block: inside_block)
121
+ if klass
122
+ if inside_block
123
+ # Nested component inside a collection: per-item data + JS render function
124
+ key = record_block_nested_component(send_node, class_name)
125
+ block_var = current_block_context[:var]
126
+ prop = s(:send, s(:lvar, block_var), :[], s(:str, key))
127
+ fn_name = :"_render_#{class_name.underscore}"
128
+ return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
129
+ s(:send, nil, fn_name, prop))
130
+ else
131
+ key = record_nested_component(send_node, class_name)
132
+ return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
133
+ s(:send, nil, :"_render_#{key}", s(:lvar, key.to_sym)))
134
+ end
135
+ end
136
+ end
137
+
138
+ # Inside a .each block: expressions referencing the block variable
139
+ # become per-item computed fields. Must be checked before ivar_chain?
140
+ # because expressions like @message.labels.include?(label) have an
141
+ # ivar chain receiver but depend on the block variable.
142
+ if in_block_context? && contains_block_var?(send_node)
143
+ raw = html_producing?(send_node)
144
+ key = record_block_computed(send_node, raw: raw)
145
+ block_var = current_block_context[:var]
146
+ prop = s(:send, s(:lvar, block_var), :[], s(:str, key))
147
+ if raw
148
+ return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, prop)
149
+ else
150
+ return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
151
+ s(:send, nil, :String, prop))
152
+ end
153
+ end
154
+
155
+ # Fallback: any remaining expression that doesn't reference block
156
+ # variables becomes a server-computed variable.
157
+ unless lvar_chain?(send_node) || contains_lvar?(send_node)
158
+ raw = html_producing?(send_node)
159
+ key = record_extraction(send_node, raw: raw)
160
+ if raw
161
+ return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, s(:lvar, key.to_sym))
162
+ else
163
+ return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+,
164
+ s(:send, nil, :String, s(:lvar, key.to_sym)))
165
+ end
166
+ end
167
+
168
+ defined?(super) ? super : nil
169
+ end
170
+
171
+ # Hook called by ERB filter for block expressions inside <%= expr do %>...<% end %>.
172
+ # Handles render(Component.new(...)) { block } by extracting as raw server-evaluated HTML.
173
+ def process_erb_block_append(block_node)
174
+ call_node, _args, body = block_node.children
175
+
176
+ if render_component_call?(call_node)
177
+ block_html = extract_block_html(body)
178
+ call_source = rebuild_source(call_node)
179
+ full_source = block_html ?
180
+ "#{call_source} { #{block_html.inspect}.html_safe }" :
181
+ call_source
182
+ key = record_extraction(nil, raw: true, source_override: full_source)
183
+ return s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, s(:lvar, key.to_sym))
184
+ end
185
+
186
+ defined?(super) ? super : nil
187
+ end
188
+
189
+ # Catches server-evaluable expressions in non-output context
190
+ # (if/unless conditions, ternaries, collection targets).
191
+ def on_send(node)
192
+ return super unless @erb_bufvar
193
+
194
+ if server_evaluable?(node) && !contains_lvar?(node)
195
+ source = rebuild_source(node)
196
+
197
+ # Collection being iterated: assign a unique key per loop
198
+ if in_block_context? && current_block_context[:collection_source] == source && current_block_context[:collection_key].nil?
199
+ key = record_collection_extraction(node)
200
+ current_block_context[:collection_key] = key
201
+ else
202
+ key = record_extraction(node)
203
+ end
204
+
205
+ return s(:lvar, key.to_sym)
206
+ end
207
+
208
+ super
209
+ end
210
+
211
+ # Catches bare constant access in non-output context
212
+ # (e.g., if SomeConstant::VALUE in conditions).
213
+ def on_const(node)
214
+ return super unless @erb_bufvar
215
+
216
+ key = record_extraction(node)
217
+ s(:lvar, key.to_sym)
218
+ end
219
+
220
+ private
221
+
222
+ # --- Tag builder ---
223
+
224
+ def process_tag_builder_append(send_node)
225
+ _target, method, *args = send_node.children
226
+ tag_name = method.to_s
227
+
228
+ # Separate positional args from keyword hash
229
+ positional = args.dup
230
+ hash_arg = (ast_node?(positional.last) && positional.last.type == :hash) ? positional.pop : nil
231
+ content_node = positional.first
232
+
233
+ content_expr = content_node ? process_tag_arg(content_node) : s(:str, "")
234
+
235
+ if hash_arg
236
+ attrs_expr = process_tag_attrs(hash_arg)
237
+ call = s(:send, nil, :_tag, s(:str, tag_name), content_expr, attrs_expr)
238
+ else
239
+ call = s(:send, nil, :_tag, s(:str, tag_name), content_expr)
240
+ end
241
+
242
+ # _tag returns raw HTML (handles its own escaping)
243
+ s(:op_asgn, s(:lvasgn, @erb_bufvar), :+, call)
244
+ end
245
+
246
+ def process_tag_arg(node)
247
+ return process(node) unless ast_node?(node)
248
+
249
+ if node.type == :array
250
+ return s(:array, *node.children.map { |child| process_tag_arg(child) })
251
+ end
252
+
253
+ if ivar_chain?(node)
254
+ key = record_extraction(node)
255
+ return s(:lvar, key.to_sym)
256
+ end
257
+
258
+ if node.type == :send && node.children[0].nil? && contains_ivar?(node)
259
+ key = record_extraction(node)
260
+ return s(:lvar, key.to_sym)
261
+ end
262
+
263
+ if in_block_context? && contains_block_var?(node)
264
+ key = record_block_computed(node)
265
+ block_var = current_block_context[:var]
266
+ return s(:send, s(:lvar, block_var), :[], s(:str, key))
267
+ end
268
+
269
+ process(node)
270
+ end
271
+
272
+ def process_tag_attrs(hash_node)
273
+ pairs = hash_node.children.map do |pair|
274
+ next pair unless ast_node?(pair) && pair.type == :pair
275
+ key_node, value_node = pair.children
276
+ js_key = (ast_node?(key_node) && key_node.type == :sym) ? s(:str, key_node.children[0].to_s) : key_node
277
+ processed_value = process_tag_arg(value_node)
278
+ s(:pair, js_key, processed_value)
279
+ end
280
+ s(:hash, *pairs)
281
+ end
282
+
283
+ # --- Render component detection ---
284
+
285
+ # Detects render(SomeConst.new(...)) pattern
286
+ def render_component_call?(node)
287
+ return false unless node&.type == :send
288
+ target, method, *args = node.children
289
+ return false unless target.nil? && method == :render && args.length == 1
290
+ arg = args.first
291
+ arg&.type == :send && arg.children[1] == :new
292
+ end
293
+
294
+ # Walks Erubi-processed block body to extract static HTML strings
295
+ # from buffer append operations (_buf << "html" or _buf += "html")
296
+ def extract_block_html(body)
297
+ return nil unless body
298
+ strings = []
299
+ collect_buffer_strings(body, strings)
300
+ strings.empty? ? nil : strings.join
301
+ end
302
+
303
+ def collect_buffer_strings(node, strings)
304
+ return unless ast_node?(node)
305
+ case node.type
306
+ when :begin
307
+ node.children.each { |child| collect_buffer_strings(child, strings) }
308
+ when :op_asgn, :send
309
+ node.children.each do |child|
310
+ next unless ast_node?(child)
311
+ collect_str_content(child, strings)
312
+ end
313
+ end
314
+ end
315
+
316
+ def collect_str_content(node, strings)
317
+ case node.type
318
+ when :str
319
+ strings << node.children[0]
320
+ when :dstr
321
+ node.children.each { |c| strings << c.children[0] if ast_node?(c) && c.type == :str }
322
+ when :send
323
+ # handle .freeze wrapper: str("...").freeze or dstr(...).freeze
324
+ if node.children[1] == :freeze && ast_node?(node.children[0])
325
+ collect_str_content(node.children[0], strings)
326
+ end
327
+ end
328
+ end
329
+
330
+ # --- Key generation ---
331
+
332
+ def next_key
333
+ key = "v#{@key_counter}"
334
+ @key_counter += 1
335
+ key
336
+ end
337
+
338
+ # --- Block context tracking ---
339
+
340
+ def in_block_context?
341
+ !@block_context_stack.empty?
342
+ end
343
+
344
+ def current_block_context
345
+ @block_context_stack.last
346
+ end
347
+
348
+ def contains_block_var?(node)
349
+ return false unless in_block_context?
350
+ contains_specific_lvar?(node, current_block_context[:var])
351
+ end
352
+
353
+ def contains_specific_lvar?(node, var_name)
354
+ return false unless ast_node?(node)
355
+ return true if node.type == :lvar && node.children[0] == var_name
356
+ node.children.any? { |child| ast_node?(child) && contains_specific_lvar?(child, var_name) }
357
+ end
358
+
359
+ def record_block_computed(node, raw: false)
360
+ source = rebuild_source(node)
361
+ computed = current_block_context[:computed]
362
+
363
+ # Dedup within this block: same source reuses same key
364
+ existing = computed.find { |_, info| info[:source] == source }
365
+ return existing[0] if existing
366
+
367
+ key = next_key
368
+ computed[key] = { source: source, raw: raw }
369
+ key
370
+ end
371
+
372
+ def flush_block_computed(context)
373
+ return unless @extraction_output
374
+ key = context[:collection_key]
375
+ return unless key
376
+
377
+ @extraction_output[:collection_computed] ||= {}
378
+ @extraction_output[:collection_computed][key] = {
379
+ block_var: context[:var].to_s,
380
+ expressions: context[:computed]
381
+ }
382
+ end
383
+
384
+ # --- Nested component recording ---
385
+
386
+ def record_block_nested_component(send_node, class_name)
387
+ new_call = send_node.children[2]
388
+ hash_node = new_call.children[2]
389
+
390
+ kwargs = {}
391
+ if hash_node && ast_node?(hash_node) && hash_node.type == :hash
392
+ hash_node.children.each do |pair|
393
+ next unless ast_node?(pair) && pair.type == :pair
394
+ kwarg_name = pair.children[0].children[0].to_s
395
+ kwarg_source = rebuild_source(pair.children[1])
396
+ kwargs[kwarg_name] = kwarg_source
397
+ end
398
+ end
399
+
400
+ key = next_key
401
+ computed = current_block_context[:computed]
402
+ computed[key] = {
403
+ source: nil,
404
+ raw: true,
405
+ nested_component: { class_name: class_name, kwargs: kwargs }
406
+ }
407
+ key
408
+ end
409
+
410
+ def record_nested_component(send_node, class_name)
411
+ key = "_nc#{@nested_counter}"
412
+ @nested_counter += 1
413
+
414
+ new_call = send_node.children[2] # Component.new(...)
415
+ hash_node = new_call.children[2] # kwargs hash
416
+
417
+ kwargs = {}
418
+ if hash_node && ast_node?(hash_node) && hash_node.type == :hash
419
+ hash_node.children.each do |pair|
420
+ next unless ast_node?(pair) && pair.type == :pair
421
+ kwarg_name = pair.children[0].children[0].to_s
422
+ kwarg_source = rebuild_source(pair.children[1])
423
+ kwargs[kwarg_name] = kwarg_source
424
+ end
425
+ end
426
+
427
+ @extraction_output[:nested_components] ||= {}
428
+ @extraction_output[:nested_components][key] = {
429
+ class_name: class_name,
430
+ kwargs: kwargs
431
+ }
432
+
433
+ key
434
+ end
435
+
436
+ # --- AST inspection helpers ---
437
+
438
+ def ivar_chain?(node)
439
+ return false unless node && ast_node?(node)
440
+ return true if node.type == :ivar
441
+ return false unless node.type == :send
442
+ target = node.children[0]
443
+ target && ivar_chain?(target)
444
+ end
445
+
446
+ def ivar_chain_to_name(node)
447
+ parts = []
448
+ current = node
449
+ while current && ast_node?(current) && current.type == :send
450
+ parts.unshift(current.children[1].to_s.delete_suffix("?"))
451
+ current = current.children[0]
452
+ end
453
+ parts.join("_")
454
+ end
455
+
456
+ def const_chain?(node)
457
+ return false unless node && ast_node?(node)
458
+ return true if node.type == :const
459
+ return false unless node.type == :send
460
+ target = node.children[0]
461
+ target && (const_chain?(target) || ivar_chain?(target))
462
+ end
463
+
464
+ # An expression that can't run in JS and must be server-evaluated.
465
+ # Covers ivar chains, const chains, and bare helpers referencing ivars.
466
+ def server_evaluable?(node)
467
+ return false unless node && ast_node?(node)
468
+ return false if lvar_only?(node)
469
+ ivar_chain?(node) || const_chain?(node) ||
470
+ (node.type == :send && node.children[0].nil? && !pure_lvar_args?(node))
471
+ end
472
+
473
+ def extractable?(node)
474
+ return false unless ast_node?(node)
475
+ ivar_chain?(node) || const_chain?(node) ||
476
+ (node.type == :send && node.children[0].nil? && contains_ivar?(node))
477
+ end
478
+
479
+ def contains_ivar?(node)
480
+ return false unless ast_node?(node)
481
+ return true if node.type == :ivar
482
+ node.children.any? { |child| ast_node?(child) && contains_ivar?(child) }
483
+ end
484
+
485
+ def contains_const?(node)
486
+ return false unless ast_node?(node)
487
+ return true if node.type == :const
488
+ node.children.any? { |child| ast_node?(child) && contains_const?(child) }
489
+ end
490
+
491
+ def contains_lvar?(node)
492
+ return false unless ast_node?(node)
493
+ return true if node.type == :lvar
494
+ node.children.any? { |child| ast_node?(child) && contains_lvar?(child) }
495
+ end
496
+
497
+ # Returns true if the node is purely lvar-based (no ivars, no consts)
498
+ def lvar_only?(node)
499
+ return false unless node && ast_node?(node)
500
+ return true if node.type == :lvar
501
+ return false if node.type == :ivar || node.type == :const
502
+ return false unless node.type == :send
503
+ !contains_ivar?(node) && !contains_const?(node)
504
+ end
505
+
506
+ # Returns true if a bare method call's arguments only reference lvars/literals
507
+ def pure_lvar_args?(node)
508
+ return true unless node.type == :send
509
+ _target, _method, *args = node.children
510
+ args.none? { |arg| ast_node?(arg) && (contains_ivar?(arg) || contains_const?(arg)) }
511
+ end
512
+
513
+ def lvar_chain?(node)
514
+ return false unless node && ast_node?(node)
515
+ return true if node.type == :lvar
516
+ return false unless node.type == :send
517
+ node.children[0] && lvar_chain?(node.children[0])
518
+ end
519
+
520
+ HTML_PRODUCING_METHODS = %i[content_tag link_to button_to image_tag render].to_set.freeze
521
+
522
+ def html_producing?(node)
523
+ return false unless node.type == :send
524
+ target, method = node.children
525
+ return true if tag_builder?(target)
526
+ return true if target.nil? && HTML_PRODUCING_METHODS.include?(method)
527
+ false
528
+ end
529
+
530
+ def tag_builder?(node)
531
+ node&.type == :send && node.children == [nil, :tag]
532
+ end
533
+
534
+ # --- Source reconstruction ---
535
+
536
+ def rebuild_source(node)
537
+ return "" unless ast_node?(node)
538
+ case node.type
539
+ when :ivar then node.children[0].to_s
540
+ when :lvar then node.children[0].to_s
541
+ when :const
542
+ parent, name = node.children
543
+ parent ? "#{rebuild_source(parent)}::#{name}" : name.to_s
544
+ when :str then node.children[0].inspect
545
+ when :int, :float then node.children[0].to_s
546
+ when :true then "true"
547
+ when :false then "false"
548
+ when :nil then "nil"
549
+ when :sym then ":#{node.children[0]}"
550
+ when :hash
551
+ node.children.map { |pair| rebuild_source(pair) }.join(", ")
552
+ when :pair
553
+ key, value = node.children
554
+ val_str = rebuild_source(value)
555
+ val_str = "{ #{val_str} }" if ast_node?(value) && value.type == :hash
556
+ if key.type == :sym
557
+ "#{key.children[0]}: #{val_str}"
558
+ else
559
+ "#{rebuild_source(key)} => #{val_str}"
560
+ end
561
+ when :send
562
+ target, method, *args = node.children
563
+ recv = target ? rebuild_source(target) : nil
564
+ args_src = args.map { |a| rebuild_source(a) }.join(", ")
565
+ method_str = method.to_s
566
+ if recv
567
+ args.empty? ? "#{recv}.#{method_str}" : "#{recv}.#{method_str}(#{args_src})"
568
+ else
569
+ "#{method_str}(#{args_src})"
570
+ end
571
+ else ""
572
+ end
573
+ end
574
+
575
+ # --- Extraction recording ---
576
+
577
+ # Scalar: same source reuses same key.
578
+ def record_extraction(node, raw: false, source_override: nil)
579
+ source = source_override || rebuild_source(node)
580
+
581
+ if @source_to_key.key?(source)
582
+ key = @source_to_key[source]
583
+ @extracted_raw_fields << key if raw
584
+ return key
585
+ end
586
+
587
+ key = next_key
588
+ @source_to_key[source] = key
589
+ @extracted_expressions[key] = source
590
+ @extracted_raw_fields << key if raw
591
+ flush_extraction_output
592
+ key
593
+ end
594
+
595
+ # Collection: always unique (no dedup), each loop gets its own key.
596
+ def record_collection_extraction(node)
597
+ source = rebuild_source(node)
598
+ key = next_key
599
+ @extracted_expressions[key] = source
600
+ flush_extraction_output
601
+ key
602
+ end
603
+
604
+ def flush_extraction_output
605
+ return unless @extraction_output
606
+ @extraction_output[:expressions] = @extracted_expressions.dup
607
+ @extraction_output[:raw_fields] = @extracted_raw_fields.dup
608
+ end
609
+ end
610
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveComponent
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactiveComponent
4
+ module Wrapper
5
+ module_function
6
+
7
+ def wrap(component_class, record, inner_html, stream: nil, client_state: nil, strategy: nil, component_name: nil, params: nil, template_id: nil)
8
+ dom_id_val = component_class.dom_id_for(record)
9
+
10
+ attrs = [
11
+ %(id="#{dom_id_val}"),
12
+ %(data-controller="reactive-renderer"),
13
+ %(data-reactive-renderer-template-id-value="#{template_id || component_class.template_element_id}")
14
+ ]
15
+
16
+ if stream
17
+ signed = Turbo::StreamsChannel.signed_stream_name(stream)
18
+ attrs << %(data-reactive-renderer-stream-value="#{signed}")
19
+ end
20
+
21
+ if component_class._live_actions.any?
22
+ attrs << %(data-reactive-renderer-action-url-value="#{ReactiveComponent::Engine.routes.url_helpers.reactive_component_actions_path}")
23
+ attrs << %(data-reactive-renderer-action-token-value="#{component_class.live_action_token(record)}")
24
+ attrs << %(data-reactive-renderer-field-map-value="#{ERB::Util.html_escape(component_class.expression_field_map.to_json)}")
25
+ end
26
+
27
+ if client_state
28
+ attrs << %(data-reactive-renderer-state-value="#{ERB::Util.html_escape(client_state.to_json)}")
29
+ initial_data = component_class.build_data(record, **client_state.symbolize_keys)
30
+ attrs << %(data-reactive-renderer-data-value="#{ERB::Util.html_escape(initial_data.to_json)}")
31
+ end
32
+
33
+ if strategy
34
+ attrs << %(data-reactive-renderer-strategy-value="#{strategy}")
35
+ end
36
+
37
+ if component_name
38
+ attrs << %(data-reactive-renderer-component-value="#{component_name}")
39
+ end
40
+
41
+ if params
42
+ attrs << %(data-reactive-renderer-params-value="#{ERB::Util.html_escape(params.to_json)}")
43
+ end
44
+
45
+ if ReactiveComponent.debug
46
+ debug_label = "#{component_class.name.underscore.humanize} ##{dom_id_val}"
47
+ attrs << %(data-reactive-debug="#{debug_label}")
48
+ attrs << %(class="reactive-debug-wrapper")
49
+ end
50
+
51
+ %(<div #{attrs.join(" ")}>#{inner_html}</div>).html_safe
52
+ end
53
+
54
+ def find_stream_for(component_class, record)
55
+ config = component_class._broadcast_config
56
+ return nil unless config
57
+
58
+ stream = config[:stream]
59
+ stream.is_a?(Proc) ? stream.call(record) : stream
60
+ end
61
+ end
62
+ end