live_cable 0.3.0 → 0.4.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +567 -0
  3. data/README.md +14 -2
  4. data/app/assets/javascript/controllers/live_controller.js +51 -7
  5. data/app/assets/javascript/live_cable.js +2 -0
  6. data/app/assets/javascript/loading.js +58 -6
  7. data/app/assets/javascript/subscriptions.js +292 -22
  8. data/app/channels/live_channel.rb +51 -29
  9. data/app/helpers/live_cable_helper.rb +31 -2
  10. data/lib/live_cable/action_cable_connection.rb +4 -1
  11. data/lib/live_cable/component/broadcasting.rb +37 -6
  12. data/lib/live_cable/component/dispatching.rb +36 -0
  13. data/lib/live_cable/component/lifecycle.rb +1 -1
  14. data/lib/live_cable/component/reactive_variables.rb +33 -5
  15. data/lib/live_cable/component/rendering.rb +30 -14
  16. data/lib/live_cable/component/streaming.rb +35 -4
  17. data/lib/live_cable/component.rb +4 -3
  18. data/lib/live_cable/connection/broadcasting.rb +15 -3
  19. data/lib/live_cable/connection/component_management.rb +12 -8
  20. data/lib/live_cable/connection/error_handling.rb +28 -7
  21. data/lib/live_cable/connection/messaging.rb +40 -52
  22. data/lib/live_cable/connection/state_management.rb +13 -16
  23. data/lib/live_cable/connection.rb +33 -0
  24. data/lib/live_cable/container.rb +17 -11
  25. data/lib/live_cable/defaults_signer.rb +95 -0
  26. data/lib/live_cable/delegation/array.rb +27 -1
  27. data/lib/live_cable/delegation/hash.rb +55 -1
  28. data/lib/live_cable/delegation/methods.rb +25 -1
  29. data/lib/live_cable/delegation/model.rb +12 -0
  30. data/lib/live_cable/delegator.rb +54 -6
  31. data/lib/live_cable/dispatch.rb +54 -0
  32. data/lib/live_cable/engine.rb +3 -1
  33. data/lib/live_cable/forbidden.rb +7 -0
  34. data/lib/live_cable/identity_digest.rb +27 -0
  35. data/lib/live_cable/model_observer.rb +37 -0
  36. data/lib/live_cable/observer_tracking.rb +10 -1
  37. data/lib/live_cable/predicate_builder.rb +13 -0
  38. data/lib/live_cable/render_context.rb +82 -8
  39. data/lib/live_cable/rendering/controller_renderer.rb +26 -0
  40. data/lib/live_cable/rendering/dependency_visitor.rb +68 -24
  41. data/lib/live_cable/rendering/handler.rb +1 -1
  42. data/lib/live_cable/rendering/method_analyzer.rb +18 -4
  43. data/lib/live_cable/rendering/method_collector.rb +13 -5
  44. data/lib/live_cable/rendering/partial.rb +13 -1
  45. data/lib/live_cable/rendering/partial_renderer.rb +59 -7
  46. data/lib/live_cable/rendering/render_result.rb +5 -0
  47. data/lib/live_cable/rendering/renderer.rb +18 -14
  48. data/lib/live_cable/rendering/view_buffer.rb +21 -0
  49. data/lib/live_cable/testing/render_state.rb +5 -3
  50. data/lib/live_cable/testing/test_channel.rb +6 -0
  51. data/lib/live_cable/testing/test_component.rb +3 -3
  52. data/lib/live_cable/testing.rb +9 -5
  53. data/lib/live_cable/version.rb +1 -1
  54. data/lib/live_cable.rb +32 -3
  55. metadata +9 -1
@@ -51,12 +51,21 @@ module LiveCable
51
51
  observers.delete(observer)
52
52
  end
53
53
 
54
+ # @api private
55
+ # @param other [ObserverTracking] starts notifying this object's observers too
56
+ # @return [void]
57
+ def share_live_cable_observers_with(other)
58
+ live_cable_observers.each do |variable, observers|
59
+ observers.each { |observer| other.add_live_cable_observer(observer, variable) }
60
+ end
61
+ end
62
+
54
63
  def initialize_dup(other)
55
64
  super
56
65
  @live_cable_observers = {}
57
66
  end
58
67
 
59
- def initialize_clone(other)
68
+ def initialize_clone(other, **)
60
69
  super
61
70
  @live_cable_observers = {}
62
71
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ # Lets where(column: value) see the Array or Hash inside a reactive value
5
+ # instead of treating the wrapper as a single scalar.
6
+ module PredicateBuilder
7
+ def initialize(...)
8
+ super
9
+
10
+ register_handler(Delegator, ->(attribute, value) { build(attribute, value.__getobj__) })
11
+ end
12
+ end
13
+ end
@@ -30,16 +30,17 @@ module LiveCable
30
30
  result
31
31
  end
32
32
 
33
- # Returns children from the previous context that came from parts which were
34
- # skipped (not rendered) in this render cycle. These children should not be
35
- # destroyed — their part simply didn't re-evaluate.
33
+ # Carried children stay out of #children, which broadcast_changeset reads as
34
+ # "rendered this cycle": a child there gets no render of its own that cycle.
36
35
  #
37
- # @param previous_context [RenderContext]
36
+ # @param previous [RenderContext]
37
+ def inherit_skipped(previous)
38
+ @children_by_part.reverse_merge!(previous.children_by_part)
39
+ end
40
+
38
41
  # @return [Array<LiveCable::Component>]
39
- def preserved_children_from(previous_context)
40
- previous_context.children_by_part.each_with_object([]) do |(part, part_children), preserved|
41
- preserved.concat(part_children) unless @children_by_part.key?(part)
42
- end
42
+ def owned_children
43
+ children_by_part.values.flatten.uniq
43
44
  end
44
45
 
45
46
  def root?
@@ -78,9 +79,82 @@ module LiveCable
78
79
  component.live_connection
79
80
  end
80
81
 
82
+ # @param delegator [LiveCable::Delegator]
83
+ # @return [Object] the value the template sees
84
+ def unwrap(delegator)
85
+ value = delegator.__getobj__
86
+
87
+ unless tracked_values.key?(value)
88
+ tracked_values[value] = [delegator, []]
89
+ unindexed << delegator
90
+ end
91
+
92
+ value
93
+ end
94
+
95
+ # Maps a prop back to tracked values, so a child's changes still mark this
96
+ # component's variables, even through a collection built in the template.
97
+ def track(value)
98
+ return value unless Delegator.supported?(value)
99
+
100
+ while (delegator = unindexed.shift)
101
+ index_nested(delegator, delegator.__getobj__, [])
102
+ end
103
+
104
+ delegator, path = tracked_values[value]
105
+ return path.reduce(delegator) { |nested, key| nested[key] } if delegator
106
+
107
+ sources = sources_within(value)
108
+ return value if sources.empty?
109
+
110
+ Delegator.new(value).tap do |wrapped|
111
+ sources.each_key { |source| source.share_live_cable_observers_with(wrapped) }
112
+ end
113
+ end
114
+
81
115
  private
82
116
 
83
117
  # @return [LiveCable::RenderContext, nil]
84
118
  attr_reader :root
119
+
120
+ # @return [Hash{Object => Array(LiveCable::Delegator, Array)}] by identity
121
+ def tracked_values
122
+ @tracked_values ||= {}.compare_by_identity
123
+ end
124
+
125
+ # @return [Array<LiveCable::Delegator>]
126
+ def unindexed
127
+ @unindexed ||= []
128
+ end
129
+
130
+ def index_nested(delegator, value, path)
131
+ nested_entries(value).each do |key, child|
132
+ next if !Delegator.supported?(child) || tracked_values.key?(child)
133
+
134
+ tracked_values[child] = [delegator, path + [key]]
135
+ index_nested(delegator, child, path + [key])
136
+ end
137
+ end
138
+
139
+ # @return [Hash{LiveCable::Delegator => true}] by identity
140
+ def sources_within(value, sources = {}.compare_by_identity, seen = {}.compare_by_identity)
141
+ nested_entries(value).map(&:last).each do |child|
142
+ next if !Delegator.supported?(child) || seen.key?(child)
143
+
144
+ seen[child] = true
145
+ delegator, = tracked_values[child]
146
+ delegator ? sources[delegator] = true : sources_within(child, sources, seen)
147
+ end
148
+
149
+ sources
150
+ end
151
+
152
+ def nested_entries(value)
153
+ case value
154
+ when Hash then value.each_pair
155
+ when Array then value.each_with_index.map { |child, index| [index, child] }
156
+ else []
157
+ end
158
+ end
85
159
  end
86
160
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ module Rendering
5
+ # Builds the controller ApplicationController.renderer renders with, from
6
+ # the same env, so a connection can keep one and take a view from it per
7
+ # render instead of paying for a controller, request and response each time.
8
+ class ControllerRenderer < ActionController::Renderer
9
+ # @param controller_class [Class]
10
+ # @return [ActionController::Base]
11
+ def self.controller_for(controller_class)
12
+ self.for(controller_class, nil, controller_class.renderer.defaults).build_controller
13
+ end
14
+
15
+ def build_controller
16
+ request = ActionDispatch::Request.new(env_for_request)
17
+ request.routes = controller._routes
18
+
19
+ controller.new.tap do |instance|
20
+ instance.set_request!(request)
21
+ instance.set_response!(controller.make_response!(request))
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+
3
5
  module LiveCable
4
6
  module Rendering
5
7
  class DependencyVisitor < Prism::Visitor
8
+ # @return [Array<Symbol>]
9
+ attr_reader :variable_calls
10
+
6
11
  # @return [Array<Symbol>]
7
12
  attr_reader :local_reads
8
13
 
@@ -14,32 +19,40 @@ module LiveCable
14
19
 
15
20
  def initialize
16
21
  super
22
+ @variable_calls = []
17
23
  @local_reads = []
18
24
  @local_writes = []
19
25
  @component_method_calls = Set.new
26
+ @scope_depth = 0
20
27
  end
21
28
 
22
- # Track all local variable reads
29
+ def visit_block_node(node) = nested_scope { super }
30
+
31
+ def visit_lambda_node(node) = nested_scope { super }
32
+
33
+ # Prism never resolves a name past a def, so nothing inside one counts as top level
34
+ def visit_def_node(node) = nested_scope { super }
35
+
36
+ # Track reads of top-level locals
23
37
  # @param node [Prism::LocalVariableReadNode]
24
38
  # @return [void]
25
39
  def visit_local_variable_read_node(node)
26
- @local_reads |= [node.name]
40
+ @local_reads |= [node.name] if top_level?(node)
27
41
  super
28
42
  end
29
43
 
30
- # Track variable method calls (e.g., `foo` without parens)
31
- # Also track component.method_name calls
44
+ # Track argument-less receiverless calls (e.g., `foo`, `foo()` or the value
45
+ # of a `foo:` hash shorthand) and calls on component or self (e.g.,
46
+ # `component.foo`), which read the component too
32
47
  # @param node [Prism::CallNode]
33
48
  # @return [void]
34
49
  def visit_call_node(node)
35
- # Track variable calls (implicit self)
36
- @local_reads |= [node.name] if node.variable_call?
37
-
38
- # Track component.method_name calls
39
- if component_receiver?(node.receiver)
40
- @component_method_calls << node.name
50
+ if node.variable_call? || bare_call?(node) || explicit_component_receiver?(node.receiver)
51
+ @variable_calls |= [node.name]
41
52
  end
42
53
 
54
+ @component_method_calls << node.name if component_receiver?(node.receiver)
55
+
43
56
  super
44
57
  end
45
58
 
@@ -47,7 +60,15 @@ module LiveCable
47
60
  # @param node [Prism::LocalVariableWriteNode]
48
61
  # @return [void]
49
62
  def visit_local_variable_write_node(node)
50
- @local_writes |= [node.name]
63
+ @local_writes |= [node.name] if top_level?(node)
64
+ super
65
+ end
66
+
67
+ # Track multiple assignment, rescue and pattern targets
68
+ # @param node [Prism::LocalVariableTargetNode]
69
+ # @return [void]
70
+ def visit_local_variable_target_node(node)
71
+ @local_writes |= [node.name] if top_level?(node)
51
72
  super
52
73
  end
53
74
 
@@ -55,8 +76,7 @@ module LiveCable
55
76
  # @param node [Prism::LocalVariableOperatorWriteNode]
56
77
  # @return [void]
57
78
  def visit_local_variable_operator_write_node(node)
58
- @local_writes |= [node.name]
59
- @local_reads |= [node.name] # Reads before writing
79
+ read_and_write(node)
60
80
  super
61
81
  end
62
82
 
@@ -64,8 +84,7 @@ module LiveCable
64
84
  # @param node [Prism::LocalVariableAndWriteNode]
65
85
  # @return [void]
66
86
  def visit_local_variable_and_write_node(node)
67
- @local_writes |= [node.name]
68
- @local_reads |= [node.name]
87
+ read_and_write(node)
69
88
  super
70
89
  end
71
90
 
@@ -73,24 +92,49 @@ module LiveCable
73
92
  # @param node [Prism::LocalVariableOrWriteNode]
74
93
  # @return [void]
75
94
  def visit_local_variable_or_write_node(node)
76
- @local_writes |= [node.name]
77
- @local_reads |= [node.name]
95
+ read_and_write(node)
78
96
  super
79
97
  end
80
98
 
81
99
  private
82
100
 
83
- # Check if receiver is a reference to 'component'
101
+ def nested_scope
102
+ @scope_depth += 1
103
+ yield
104
+ ensure
105
+ @scope_depth -= 1
106
+ end
107
+
108
+ # Prism's depth is how many scopes up it found the local
109
+ def top_level?(node)
110
+ node.depth == @scope_depth
111
+ end
112
+
113
+ def read_and_write(node)
114
+ return unless top_level?(node)
115
+
116
+ @local_reads |= [node.name]
117
+ @local_writes |= [node.name]
118
+ end
119
+
120
+ # @param node [Prism::CallNode]
121
+ # @return [Boolean]
122
+ def bare_call?(node)
123
+ node.receiver.nil? && node.arguments.nil? && node.block.nil?
124
+ end
125
+
84
126
  # @param receiver [Prism::Node, nil]
85
127
  # @return [Boolean]
86
128
  def component_receiver?(receiver)
87
- # No receiver, calling something lke live_id or any component method
88
- return true if receiver.nil?
89
-
90
- # Ignore these because they're not component methods, just output methods
91
- return false if receiver.try(:name) == :@output_buffer
129
+ receiver.nil? || explicit_component_receiver?(receiver)
130
+ end
92
131
 
93
- # If there is a receiver, it must be a component method call or local variable read
132
+ # Part code runs on the renderer, which hands unknown names to the component,
133
+ # so `self.foo` reads the component just as `component.foo` does
134
+ # @param receiver [Prism::Node, nil]
135
+ # @return [Boolean]
136
+ def explicit_component_receiver?(receiver)
137
+ return true if receiver.is_a?(Prism::SelfNode)
94
138
  return false unless receiver.try(:name) == :component
95
139
 
96
140
  receiver.is_a?(Prism::CallNode) || receiver.is_a?(Prism::LocalVariableReadNode)
@@ -7,7 +7,7 @@ module LiveCable
7
7
  parse_result = ::Herb.parse(source, track_whitespace: true)
8
8
  ast = parse_result.value
9
9
 
10
- engine = Renderer.new
10
+ engine = Renderer.new(template.short_identifier)
11
11
  compiler = Compiler.new(engine)
12
12
  ast.accept(compiler)
13
13
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+
3
5
  module LiveCable
4
6
  module Rendering
5
7
  # Analyzes component methods to track their dependencies on reactive variables
@@ -19,11 +21,15 @@ module LiveCable
19
21
  def analyze_all_methods
20
22
  return dependencies if analyzed
21
23
 
22
- # Get the source file for this component
23
- source_location = Object.const_source_location(component_class.name)
24
+ # Classes built with Class.new have no source file to analyze; see analyzable?
25
+ class_name = component_class.name
26
+ file_path = class_name && Object.const_source_location(class_name)&.first
24
27
 
25
- file_path = source_location[0]
26
- return {} unless File.exist?(file_path)
28
+ unless file_path && File.exist?(file_path)
29
+ @analyzed = true
30
+ @analyzable = false
31
+ return dependencies
32
+ end
27
33
 
28
34
  # Parse the entire file once
29
35
  source_code = File.read(file_path)
@@ -35,9 +41,17 @@ module LiveCable
35
41
 
36
42
  @dependencies = collector.dependencies
37
43
  @analyzed = true
44
+ @analyzable = true
38
45
  dependencies
39
46
  end
40
47
 
48
+ # @return [Boolean] false when the class has no source file, so method
49
+ # dependencies are unknown and callers must assume any change matters
50
+ def analyzable?
51
+ analyze_all_methods unless analyzed
52
+ @analyzable
53
+ end
54
+
41
55
  # Get dependencies for a specific method (analyzes all if not done yet)
42
56
  # @param method_name [Symbol] The method name
43
57
  # @return [Hash, nil] { methods: Set, reactive_vars: Set }
@@ -15,7 +15,7 @@ module LiveCable
15
15
 
16
16
  @component_class = component_class
17
17
  @dependencies = {}
18
- @reactive_vars = component_class.all_reactive_variables
18
+ @reactive_vars = component_class.all_reactive_variables | component_class.shared_variables
19
19
  end
20
20
 
21
21
  # Visit each method definition
@@ -24,6 +24,8 @@ module LiveCable
24
24
  def visit_def_node(node)
25
25
  method_name = node.name
26
26
 
27
+ return if node.receiver
28
+
27
29
  # Skip private/internal methods
28
30
  return super if %i[render render_in].include?(method_name)
29
31
 
@@ -31,14 +33,20 @@ module LiveCable
31
33
  visitor = MethodDependencyVisitor.new(component_class, reactive_vars)
32
34
  visitor.visit(node)
33
35
 
34
- @dependencies[method_name] = {
35
- methods: visitor.method_calls,
36
- reactive_vars: visitor.reactive_var_reads,
37
- }
36
+ # Nested and sibling classes in the same file share this table, so a
37
+ # clashing name may only add dependencies, never replace them.
38
+ entry = @dependencies[method_name] ||= { methods: Set.new, reactive_vars: Set.new }
39
+ entry[:methods].merge(visitor.method_calls)
40
+ entry[:reactive_vars].merge(visitor.reactive_var_reads)
38
41
 
39
42
  super
40
43
  end
41
44
 
45
+ # Methods in `class << self` are never instance methods a template calls
46
+ # @param node [Prism::SingletonClassNode]
47
+ # @return [void]
48
+ def visit_singleton_class_node(node); end
49
+
42
50
  private
43
51
 
44
52
  # @return [Class]
@@ -5,9 +5,10 @@ require_relative 'partial_renderer'
5
5
  module LiveCable
6
6
  module Rendering
7
7
  class Partial
8
- def initialize(parts, metadata)
8
+ def initialize(parts, metadata, identifier)
9
9
  @parts = parts
10
10
  @metadata = metadata
11
+ @identifier = identifier
11
12
  @renderer_class = build_renderer_class
12
13
  end
13
14
 
@@ -16,6 +17,14 @@ module LiveCable
16
17
  renderer_class.new(component, parts, view_context)
17
18
  end
18
19
 
20
+ # ActionView calls this when a template is rendered as a partial or a plain
21
+ # template; only Component#render_in can render a Partial.
22
+ def to_s
23
+ raise LiveCable::Error,
24
+ "#{identifier} is a component template and can only be rendered by its component. " \
25
+ 'Render the component with live(...) or render(component), or make the partial a .html.erb template.'
26
+ end
27
+
19
28
  private
20
29
 
21
30
  # @return [Array]
@@ -24,6 +33,9 @@ module LiveCable
24
33
  # @return [Array]
25
34
  attr_reader :metadata
26
35
 
36
+ # @return [String]
37
+ attr_reader :identifier
38
+
27
39
  # @return [Class<PartialRenderer>]
28
40
  attr_reader :renderer_class
29
41
 
@@ -11,6 +11,7 @@ module LiveCable
11
11
  @component = component
12
12
  @parts = parts
13
13
  @view_context = view_context
14
+ bind_shadowed_names
14
15
  end
15
16
 
16
17
  def render_changes(changes = :all)
@@ -25,15 +26,19 @@ module LiveCable
25
26
  @method_deps_cache = {} # Cache method dependency expansion for this render cycle
26
27
  parts.each_with_index.with_object([]) do |item, acc|
27
28
  part, index = item
28
- result = view_context.render_part(index) { send("render_part_#{index}", changes) }
29
+ result = view_context.render_part(index) { __send__("render_part_#{index}", changes) }
29
30
  acc << result unless part[0] == :code
30
31
  end
31
32
  end
32
33
 
33
34
  # Pass through renders to the view context, otherwise method_missing will pass
34
35
  # to the component instead since it also has a render method
35
- def render(*)
36
- view_context.render(*)
36
+ def render(*, &block)
37
+ return view_context.render(*) unless block
38
+
39
+ view_context.render(*) do |*args|
40
+ with_view_buffer { block.call(*args) }
41
+ end
37
42
  end
38
43
 
39
44
  def mark_locals_dirty(locals)
@@ -80,6 +85,8 @@ module LiveCable
80
85
  # Always render on template switch (all dynamic parts must render)
81
86
  return false if changes == :dynamic
82
87
 
88
+ return false if component_method_calls.any? && !component.class.method_dependencies_analyzer.analyzable?
89
+
83
90
  # Expand component.method_name calls to their transitive dependencies
84
91
  expanded_deps = expand_component_method_dependencies(component_method_calls)
85
92
  all_component_deps = component_dependencies | expanded_deps
@@ -94,17 +101,18 @@ module LiveCable
94
101
  true
95
102
  end
96
103
 
97
- def method_missing(method, ...)
98
- if locals.key?(method)
104
+ def method_missing(method, *args, **kwargs, &block)
105
+ # A local is only ever a bare name; with arguments it's a method call, as in plain ERB
106
+ if locals.key?(method) && args.empty? && kwargs.empty? && block.nil?
99
107
  return locals[method]
100
108
  end
101
109
 
102
110
  if component.respond_to?(method)
103
- return component.public_send(method, ...)
111
+ return view_context.live_cable_unwrap(component.public_send(method, *args, **kwargs, &block))
104
112
  end
105
113
 
106
114
  if view_context.respond_to?(method)
107
- return view_context.public_send(method, ...)
115
+ return forwarding_to_view_buffer { view_context.public_send(method, *args, **kwargs, &block) }
108
116
  end
109
117
 
110
118
  super
@@ -137,9 +145,53 @@ module LiveCable
137
145
  view_context.with_output_buffer(@output_buffer, &).to_s
138
146
  end
139
147
 
148
+ # Template blocks write to this renderer's @output_buffer, but a partial
149
+ # yields them while its own buffer is the one capturing.
150
+ def with_view_buffer
151
+ buffer = @output_buffer
152
+ @output_buffer = view_context.output_buffer
153
+ yield
154
+ ensure
155
+ @output_buffer = buffer
156
+ end
157
+
158
+ def forwarding_to_view_buffer
159
+ buffer = @output_buffer
160
+ @output_buffer = ViewBuffer.new(view_context)
161
+ yield
162
+ ensure
163
+ @output_buffer = buffer
164
+ end
165
+
140
166
  def store_local(name, value)
141
167
  locals[name] = value
142
168
  end
169
+
170
+ # Kernel and Object names never reach method_missing. Bind the ones the component
171
+ # or view context define themselves; a wrapper falling back to Kernel breaks lambda.
172
+ def bind_shadowed_names
173
+ names = self.class.metadata.compact.flat_map { |part| part[:component_method_calls] || [] }.uniq
174
+ names = names.select { |name| Object.method_defined?(name) || Object.private_method_defined?(name) }
175
+
176
+ # The renderer's respond_to? already checks locals, the component and the view context.
177
+ (names - [:respond_to?]).each do |name|
178
+ target = [component, view_context].find do |object|
179
+ if Object.private_method_defined?(name)
180
+ object.respond_to?(name)
181
+ else
182
+ object.class.public_method_defined?(name) &&
183
+ object.class.instance_method(name).owner != Object.instance_method(name).owner
184
+ end
185
+ end
186
+ next unless target
187
+
188
+ define_singleton_method(name) do |*args, **kwargs, &block|
189
+ next target.public_send(name, *args, **kwargs, &block) if target.equal?(component)
190
+
191
+ forwarding_to_view_buffer { target.public_send(name, *args, **kwargs, &block) }
192
+ end
193
+ end
194
+ end
143
195
  end
144
196
  end
145
197
  end
@@ -26,6 +26,11 @@ module LiveCable
26
26
  parts.compact.empty?
27
27
  end
28
28
 
29
+ # Nothing re-rendered here or in any child
30
+ def blank?
31
+ empty? && child_results.values.all?(&:blank?)
32
+ end
33
+
29
34
  def as_json
30
35
  {
31
36
  h: template_hash,
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+
3
5
  module LiveCable
4
6
  module Rendering
5
7
  class Renderer < ::Herb::Engine
6
8
  # rubocop:disable Lint/MissingSuper
7
- def initialize
9
+ def initialize(identifier = nil)
10
+ @identifier = identifier
8
11
  @newline_pending = 0
9
12
  @parts = []
10
13
  @src = +''
@@ -20,11 +23,14 @@ module LiveCable
20
23
 
21
24
  def src
22
25
  metadata = build_metadata
23
- "::LiveCable::Rendering::Partial.new(#{parts.inspect}, #{metadata.inspect})"
26
+ "::LiveCable::Rendering::Partial.new(#{parts.inspect}, #{metadata.inspect}, #{identifier.inspect})"
24
27
  end
25
28
 
26
29
  private
27
30
 
31
+ # @return [String, nil]
32
+ attr_reader :identifier
33
+
28
34
  # @return [Integer]
29
35
  attr_reader :newline_pending
30
36
 
@@ -43,22 +49,20 @@ module LiveCable
43
49
  parts.map do |type, code|
44
50
  next nil if type == :static || code.nil? || code.empty?
45
51
 
46
- parsed = Prism.parse(code).value
47
- locals_defined_here = parsed.locals || []
48
- local_check_code = +''
49
-
50
- locals_defined_here.each do |local|
51
- local_check_code << "store_local(:#{local}, #{local}) if defined?(#{local})\n"
52
- end
52
+ # Earlier parts' locals are in scope, as they would be in one ERB method body
53
+ parsed = Prism.parse(code, scopes: [accumulated_locals]).value
53
54
 
54
55
  visitor = DependencyVisitor.new
55
56
  visitor.visit(parsed)
56
57
 
57
- # Component/method dependencies: reads that aren't locals from previous parts
58
- component_dependencies = visitor.local_reads - accumulated_locals
58
+ local_check_code = +''
59
+
60
+ visitor.local_writes.each do |local|
61
+ local_check_code << "store_local(:#{local}, #{local}) if defined?(#{local})\n"
62
+ end
59
63
 
60
- # Local dependencies: reads that ARE from previous parts
61
- local_dependencies = visitor.local_reads & accumulated_locals
64
+ # A write that doesn't run, such as one in a false `if`, keeps the earlier value
65
+ local_dependencies = (visitor.local_reads | visitor.local_writes) & accumulated_locals
62
66
 
63
67
  # Track component.method_name calls separately for runtime expansion
64
68
  component_method_calls = visitor.component_method_calls.to_a
@@ -69,7 +73,7 @@ module LiveCable
69
73
  {
70
74
  type:,
71
75
  code:,
72
- component_dependencies: component_dependencies - [:component],
76
+ component_dependencies: visitor.variable_calls - [:component],
73
77
  component_method_calls:,
74
78
  local_dependencies:,
75
79
  defines_locals: visitor.local_writes,