live_cable 0.0.1 → 0.1.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +1275 -0
  4. data/app/assets/javascript/controllers/live_controller.js +79 -44
  5. data/app/assets/javascript/dom.js +161 -0
  6. data/app/assets/javascript/live_cable.js +50 -0
  7. data/app/assets/javascript/live_cable_blessing.js +1 -1
  8. data/app/assets/javascript/observer.js +74 -0
  9. data/app/assets/javascript/subscriptions.js +396 -37
  10. data/app/channels/live_channel.rb +17 -14
  11. data/app/helpers/live_cable_helper.rb +28 -39
  12. data/config/importmap.rb +9 -3
  13. data/lib/generators/live_cable/component/component_generator.rb +58 -0
  14. data/lib/generators/live_cable/component/templates/component.rb.tt +29 -0
  15. data/lib/generators/live_cable/component/templates/view.html.live.erb.tt +2 -0
  16. data/lib/live_cable/component/broadcasting.rb +30 -0
  17. data/lib/live_cable/component/identification.rb +31 -0
  18. data/lib/live_cable/component/lifecycle.rb +67 -0
  19. data/lib/live_cable/component/method_dependency_tracking.rb +22 -0
  20. data/lib/live_cable/component/reactive_variables.rb +125 -0
  21. data/lib/live_cable/component/rendering.rb +177 -0
  22. data/lib/live_cable/component/streaming.rb +43 -0
  23. data/lib/live_cable/component.rb +21 -236
  24. data/lib/live_cable/configuration.rb +29 -0
  25. data/lib/live_cable/connection/broadcasting.rb +33 -0
  26. data/lib/live_cable/connection/channel_management.rb +13 -0
  27. data/lib/live_cable/connection/component_management.rb +38 -0
  28. data/lib/live_cable/connection/error_handling.rb +40 -0
  29. data/lib/live_cable/connection/messaging.rb +84 -0
  30. data/lib/live_cable/connection/state_management.rb +56 -0
  31. data/lib/live_cable/connection.rb +11 -180
  32. data/lib/live_cable/container.rb +25 -0
  33. data/lib/live_cable/delegation/array.rb +1 -0
  34. data/lib/live_cable/delegator.rb +0 -7
  35. data/lib/live_cable/engine.rb +15 -3
  36. data/lib/live_cable/observer.rb +5 -1
  37. data/lib/live_cable/observer_tracking.rb +20 -0
  38. data/lib/live_cable/render_context.rb +55 -8
  39. data/lib/live_cable/rendering/compiler.rb +80 -0
  40. data/lib/live_cable/rendering/dependency_visitor.rb +100 -0
  41. data/lib/live_cable/rendering/handler.rb +19 -0
  42. data/lib/live_cable/rendering/method_analyzer.rb +94 -0
  43. data/lib/live_cable/rendering/method_collector.rb +51 -0
  44. data/lib/live_cable/rendering/method_dependency_visitor.rb +51 -0
  45. data/lib/live_cable/rendering/partial.rb +93 -0
  46. data/lib/live_cable/rendering/partial_renderer.rb +145 -0
  47. data/lib/live_cable/rendering/render_result.rb +38 -0
  48. data/lib/live_cable/rendering/renderer.rb +150 -0
  49. data/lib/live_cable/version.rb +5 -0
  50. data/lib/live_cable.rb +15 -15
  51. metadata +124 -4
@@ -2,135 +2,29 @@
2
2
 
3
3
  module LiveCable
4
4
  class Connection
5
- attr_reader :session_id
5
+ include ComponentManagement
6
+ include ChannelManagement
7
+ include StateManagement
8
+ include Messaging
9
+ include Broadcasting
10
+ include ErrorHandling
6
11
 
7
12
  SHARED_CONTAINER = '_shared'
8
13
 
9
14
  def initialize(request)
10
15
  @request = request
11
16
  @session_id = SecureRandom.uuid
12
- @containers = {} # @todo Use Hash.new with a proc to make a container / hash
17
+ @containers = Hash.new { |hash, key| hash[key] = Container.new }
13
18
  @components = {}
14
19
  @channels = {}
15
20
  end
16
21
 
17
- def get_component(id)
18
- components[id]
19
- end
20
-
21
- def add_component(component)
22
- component.live_connection = self
23
- components[component.live_id] = component
24
- end
25
-
26
- def remove_component(component)
27
- components.delete(component.live_id)
28
- containers.delete(component.live_id)
29
- end
30
-
31
- def set_channel(component, channel)
32
- @channels[component] = channel
33
- end
34
-
35
- def get_channel(component)
36
- @channels[component]
37
- end
38
-
39
- def remove_channel(component)
40
- @channels.delete(component)
41
- end
42
-
43
- def get(container_name, component, variable, initial_value)
44
- containers[container_name] ||= Container.new
45
- containers[container_name][variable] ||= process_initial_value(component, variable, initial_value)
46
- end
47
-
48
- def set(container_name, variable, value)
49
- dirty(container_name, variable)
50
-
51
- containers[container_name] ||= Container.new
52
- containers[container_name][variable] = value
53
- end
54
-
55
- def receive(component, data)
56
- check_csrf_token(data)
57
- reset_changeset
58
-
59
- return unless data['messages'].present?
60
-
61
- data['messages'].each do |message|
62
- action(component, message)
63
- end
64
-
65
- broadcast_changeset
66
- end
67
-
68
- def action(component, data)
69
- params = parse_params(data)
70
-
71
- if data['_action']
72
- action = data['_action']&.to_sym
73
-
74
- if action == :_reactive
75
- return reactive(component, data)
76
- end
77
-
78
- unless component.class.allowed_actions.include?(action)
79
- raise LiveCable::Error, "Unauthorized action: #{action}"
80
- end
81
-
82
- method = component.method(action)
83
-
84
- if method.arity.positive?
85
- method.call(params)
86
- else
87
- method.call
88
- end
89
- end
90
- rescue StandardError => e
91
- handle_error(component, e)
92
- end
93
-
94
- def reactive(component, data)
95
- unless component.all_reactive_variables.include?(data['name'].to_sym)
96
- raise Error, "Invalid reactive variable: #{data['name']}"
97
- end
98
-
99
- component.public_send("#{data['name']}=", data['value'])
100
- rescue StandardError => e
101
- handle_error(component, e)
102
- end
103
-
104
- def channel_name
105
- "live_#{session_id}"
106
- end
107
-
108
- def dirty(container_name, *variables)
109
- containers[container_name] ||= Container.new
110
- containers[container_name].mark_dirty(*variables)
111
- end
112
-
113
- def broadcast_changeset
114
- # Use a copy of the components since new ones can get added while rendering
115
- # and causes an issue here.
116
- components.values.dup.each do |component|
117
- container = containers[component.live_id]
118
- if container&.changed?
119
- component.render_broadcast
120
-
121
- next
122
- end
123
-
124
- shared_changeset = containers[SHARED_CONTAINER]&.changeset || []
125
-
126
- if (component.shared_reactive_variables || []).intersect?(shared_changeset)
127
- component.render_broadcast
128
- end
129
- end
130
- end
131
-
132
22
  private
133
23
 
24
+ # @return [String]
25
+ attr_reader :session_id
26
+
27
+ # @return [ActionDispatch::Request]
134
28
  attr_reader :request
135
29
 
136
30
  # @return [Hash<String, Container>]
@@ -138,68 +32,5 @@ module LiveCable
138
32
 
139
33
  # @return [Hash<String, Component>]
140
34
  attr_reader :components
141
-
142
- def check_csrf_token(data)
143
- session = request.session
144
- return unless session[:_csrf_token]
145
-
146
- token = data['_csrf_token']
147
- unless csrf_checker.valid?(session, token)
148
- raise LiveCable::Error, 'Invalid CSRF token'
149
- end
150
- end
151
-
152
- def csrf_checker
153
- @csrf_checker ||= LiveCable::CsrfChecker.new(request)
154
- end
155
-
156
- def process_initial_value(component, variable, initial_value)
157
- case initial_value
158
- when nil
159
- nil
160
- when Proc
161
- args = []
162
- args << component if initial_value.arity.positive?
163
-
164
- initial_value.call(*args)
165
- else
166
- raise Error, "Initial value for \":#{variable}\" must be a proc or nil"
167
- end
168
- rescue StandardError => e
169
- handle_error(component, e)
170
- end
171
-
172
- def parse_params(data)
173
- params = data['params'] || ''
174
-
175
- ActionController::Parameters.new(
176
- ActionDispatch::ParamBuilder.from_pairs(
177
- ActionDispatch::QueryParser.each_pair(params)
178
- )
179
- )
180
- end
181
-
182
- def reset_changeset
183
- containers.each_value(&:reset_changeset)
184
- end
185
-
186
- def handle_error(component, error)
187
- html = <<~HTML
188
- <details>
189
- <summary style="color: #f00; cursor: pointer">
190
- <strong>#{component.class.name}</strong> - #{error.class.name}: #{error.message}
191
- </summary>
192
- <small>
193
- <ol>
194
- #{error.backtrace&.map { "<li>#{it}</li>" }&.join("\n")}
195
- </ol>
196
- </small>
197
- </details>
198
- HTML
199
-
200
- component.broadcast(_refresh: html)
201
- ensure
202
- raise(error)
203
- end
204
35
  end
205
36
  end
@@ -45,6 +45,9 @@ module LiveCable
45
45
  # container[:tags] = ['ruby'] # Array - wrapped in Delegator
46
46
  # container[:user] = User.new # ActiveRecord - wrapped in Delegator
47
47
  def []=(key, value)
48
+ # Remove observer from the old value so it stops notifying this container
49
+ self[key].try(:remove_live_cable_observer, observer, key)
50
+
48
51
  # ActiveRecord models get observers attached directly
49
52
  if value.class < ModelObserver
50
53
  value.add_live_cable_observer(observer, key)
@@ -99,5 +102,27 @@ module LiveCable
99
102
  def observer
100
103
  @observer ||= Observer.new(self)
101
104
  end
105
+
106
+ # Clean up all observer references to prevent memory leaks.
107
+ # This breaks the reference chain between delegators, observers, and the container.
108
+ #
109
+ # Should be called when the component is destroyed.
110
+ #
111
+ # @return [void]
112
+ def cleanup
113
+ # Remove this container's observer from all delegated values
114
+ # Note: We only remove this specific observer, not all observers,
115
+ # because the same object might be shared across multiple containers
116
+ each do |variable, value|
117
+ if value.respond_to?(:remove_live_cable_observer)
118
+ value.remove_live_cable_observer(observer, variable)
119
+ end
120
+ end
121
+
122
+ # Clear the container's data
123
+ clear
124
+ @changeset&.clear
125
+ @observer = nil
126
+ end
102
127
  end
103
128
  end
@@ -15,6 +15,7 @@ module LiveCable
15
15
  drop
16
16
  drop_while
17
17
  filter
18
+ find
18
19
  find_all
19
20
  first
20
21
  flatten
@@ -4,13 +4,6 @@ module LiveCable
4
4
  # Delegation modules for different data types.
5
5
  # Each module defines which methods should trigger change notifications.
6
6
  module Delegation
7
- extend ActiveSupport::Autoload
8
-
9
- autoload :Array
10
- autoload :Hash
11
- autoload :Methods
12
- autoload :Model
13
-
14
7
  # Maps Ruby classes to their delegation modules.
15
8
  # When a value of one of these types is stored in a container,
16
9
  # it will be wrapped in a Delegator and extended with the appropriate module.
@@ -4,16 +4,28 @@ module LiveCable
4
4
  class Engine < ::Rails::Engine
5
5
  config.before_configuration do |app|
6
6
  # Setup autoloader to use Live namespace for components
7
- Rails.autoloaders.main.push_dir(app.root.join('app/live'), namespace: Live)
7
+ live_component_dir = app.root.join('app/live')
8
8
 
9
- # Add LiveCable to importmap
10
- app.config.importmap.paths << root.join('config/importmap.rb')
9
+ if live_component_dir.directory?
10
+ Rails.autoloaders.main.push_dir(live_component_dir, namespace: Live)
11
+ else
12
+ warn("[LiveCable Warning] #{live_component_dir} does not exist for components.")
13
+ end
14
+
15
+ # Add LiveCable to importmap (skip when using jsbundling/npm)
16
+ if app.config.respond_to?(:importmap)
17
+ app.config.importmap.paths << root.join('config/importmap.rb')
18
+ end
11
19
  end
12
20
 
13
21
  initializer 'live_cable.assets_precompile' do |app|
14
22
  app.config.assets.precompile << %w[live_cable/**/*.js]
15
23
  end
16
24
 
25
+ initializer 'live_cable.renderer' do |_app|
26
+ ActionView::Template.register_template_handler(:'live.erb', Rendering::Handler)
27
+ end
28
+
17
29
  initializer 'live_cable.active_record' do
18
30
  ActiveSupport.on_load :active_record do
19
31
  include ModelObserver
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'weakref'
4
+
3
5
  module LiveCable
4
6
  # Observer pattern implementation for tracking changes to reactive variables.
5
7
  #
@@ -21,7 +23,7 @@ module LiveCable
21
23
  class Observer
22
24
  # @param container [LiveCable::Container] The container to notify of changes
23
25
  def initialize(container)
24
- @container = container
26
+ @container = WeakRef.new(container)
25
27
  end
26
28
 
27
29
  # Notify the container that a variable has changed.
@@ -34,6 +36,8 @@ module LiveCable
34
36
  # observer.notify(:tags) # Component will re-render because :tags changed
35
37
  def notify(variable)
36
38
  container.mark_dirty(variable)
39
+ rescue WeakRef::RefError
40
+ # Container was already GC'd — nothing to do
37
41
  end
38
42
 
39
43
  private
@@ -41,6 +41,26 @@ module LiveCable
41
41
  end
42
42
  end
43
43
 
44
+ # Remove an observer from tracking changes to a specific variable.
45
+ #
46
+ # @param observer [LiveCable::Observer] The observer to remove
47
+ # @param variable [Symbol] The reactive variable name
48
+ # @return [void]
49
+ def remove_live_cable_observer(observer, variable)
50
+ observers = live_cable_observers_for(variable)
51
+ observers.delete(observer)
52
+ end
53
+
54
+ def initialize_dup(other)
55
+ super
56
+ @live_cable_observers = {}
57
+ end
58
+
59
+ def initialize_clone(other)
60
+ super
61
+ @live_cable_observers = {}
62
+ end
63
+
44
64
  private
45
65
 
46
66
  # Get the hash of all observers, keyed by variable name.
@@ -2,28 +2,75 @@
2
2
 
3
3
  module LiveCable
4
4
  class RenderContext
5
- def initialize(component)
5
+ def initialize(component, root: nil)
6
6
  @component = component
7
7
  @children = []
8
+ @root = root
9
+ @render_results = {}
10
+ @children_by_part = {}
11
+ @current_part = nil
8
12
  end
9
13
 
10
14
  # @return [Array<LiveCable::Component>]
11
15
  attr_reader :children
12
16
 
13
- def reset
14
- self.children = []
17
+ # @return [LiveCable::Component]
18
+ attr_reader :component
19
+
20
+ # @return [Hash<String, RenderResult>]
21
+ attr_reader :render_results
22
+
23
+ # @return [Hash<Integer, Array<LiveCable::Component>>]
24
+ attr_reader :children_by_part
25
+
26
+ def render_part(index)
27
+ @current_part = index
28
+ result = yield
29
+ @children_by_part[index] ||= [] unless result.nil?
30
+ result
31
+ end
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.
36
+ #
37
+ # @param previous_context [RenderContext]
38
+ # @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
43
+ end
44
+
45
+ def root?
46
+ root.nil?
47
+ end
48
+
49
+ def clear
50
+ @children = nil
51
+ @component = nil
52
+ end
53
+
54
+ # @param result [RenderResult]
55
+ def <<(result)
56
+ if root?
57
+ render_results[result.live_id] = result
58
+ else
59
+ root << result
60
+ end
15
61
  end
16
62
 
17
63
  def add_component(child)
18
- return unless component.live_connection
64
+ return unless live_connection
19
65
 
20
- component.live_connection.add_component(child)
66
+ live_connection.add_component(child)
21
67
  children << child
68
+ (@children_by_part[@current_part] ||= []) << child
22
69
  end
23
70
 
24
71
  # @return [LiveCable::Component, nil]
25
72
  def get_component(live_id)
26
- component.live_connection&.get_component(live_id)
73
+ live_connection&.get_component(live_id)
27
74
  end
28
75
 
29
76
  # @return [LiveCable::Connection]
@@ -33,7 +80,7 @@ module LiveCable
33
80
 
34
81
  private
35
82
 
36
- # @return [LiveCable::Component]
37
- attr_reader :component
83
+ # @return [LiveCable::RenderContext, nil]
84
+ attr_reader :root
38
85
  end
39
86
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ module Rendering
5
+ class Compiler < ::Herb::Engine::Compiler
6
+ def visit_erb_control_node(node)
7
+ @tokens << [:block_start]
8
+ super
9
+ @tokens << [:block_end]
10
+ end
11
+
12
+ def visit_erb_block_node(node)
13
+ @tokens << [:block_start]
14
+ super
15
+ @tokens << [:block_end]
16
+ end
17
+
18
+ def generate_output
19
+ tokens = optimize_tokens(@tokens)
20
+
21
+ tokens.map do |type, value, context|
22
+ if type == :block
23
+ value.map { |token| generate_for_token(*token) }
24
+ else
25
+ generate_for_token(type, value, context)
26
+ end
27
+
28
+ @engine.send(:finish_method, type)
29
+ end
30
+ end
31
+
32
+ def generate_for_token(type, value, context)
33
+ case type
34
+ when :text
35
+ @engine.send(:add_text, value)
36
+ when :code
37
+ @engine.send(:add_code, value)
38
+ when :expr
39
+ indicator = @escape ? '==' : '='
40
+ @engine.send(:add_expression, indicator, value)
41
+ when :expr_escaped
42
+ indicator = @escape ? '=' : '=='
43
+ @engine.send(:add_expression, indicator, value)
44
+ when :expr_block
45
+ indicator = @escape ? '==' : '='
46
+ @engine.send(:add_expression_block, indicator, value)
47
+ when :expr_block_escaped
48
+ indicator = @escape ? '=' : '=='
49
+ @engine.send(:add_expression_block, indicator, value)
50
+ end
51
+ end
52
+
53
+ def optimize_tokens(unoptimized_tokens)
54
+ tokens = super
55
+ optimized_tokens = []
56
+ block_count = 0
57
+ current_block = []
58
+
59
+ tokens.each do |token|
60
+ if token[0] == :block_start
61
+ block_count += 1
62
+ elsif token[0] == :block_end
63
+ block_count -= 1
64
+
65
+ if block_count.zero?
66
+ optimized_tokens << [:block, current_block]
67
+ current_block = []
68
+ end
69
+ elsif block_count.zero?
70
+ optimized_tokens << token
71
+ else
72
+ current_block << token
73
+ end
74
+ end
75
+
76
+ optimized_tokens
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ module Rendering
5
+ class DependencyVisitor < Prism::Visitor
6
+ # @return [Array<Symbol>]
7
+ attr_reader :local_reads
8
+
9
+ # @return [Array<Symbol>]
10
+ attr_reader :local_writes
11
+
12
+ # @return [Set<Symbol>]
13
+ attr_reader :component_method_calls
14
+
15
+ def initialize
16
+ super
17
+ @local_reads = []
18
+ @local_writes = []
19
+ @component_method_calls = Set.new
20
+ end
21
+
22
+ # Track all local variable reads
23
+ # @param node [Prism::LocalVariableReadNode]
24
+ # @return [void]
25
+ def visit_local_variable_read_node(node)
26
+ @local_reads |= [node.name]
27
+ super
28
+ end
29
+
30
+ # Track variable method calls (e.g., `foo` without parens)
31
+ # Also track component.method_name calls
32
+ # @param node [Prism::CallNode]
33
+ # @return [void]
34
+ 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
41
+ end
42
+
43
+ super
44
+ end
45
+
46
+ # Track local variable writes
47
+ # @param node [Prism::LocalVariableWriteNode]
48
+ # @return [void]
49
+ def visit_local_variable_write_node(node)
50
+ @local_writes |= [node.name]
51
+ super
52
+ end
53
+
54
+ # Track local variable operator writes (+=, ||=, etc)
55
+ # @param node [Prism::LocalVariableOperatorWriteNode]
56
+ # @return [void]
57
+ def visit_local_variable_operator_write_node(node)
58
+ @local_writes |= [node.name]
59
+ @local_reads |= [node.name] # Reads before writing
60
+ super
61
+ end
62
+
63
+ # Track local variable and writes (&&=)
64
+ # @param node [Prism::LocalVariableAndWriteNode]
65
+ # @return [void]
66
+ def visit_local_variable_and_write_node(node)
67
+ @local_writes |= [node.name]
68
+ @local_reads |= [node.name]
69
+ super
70
+ end
71
+
72
+ # Track local variable or writes (||=)
73
+ # @param node [Prism::LocalVariableOrWriteNode]
74
+ # @return [void]
75
+ def visit_local_variable_or_write_node(node)
76
+ @local_writes |= [node.name]
77
+ @local_reads |= [node.name]
78
+ super
79
+ end
80
+
81
+ private
82
+
83
+ # Check if receiver is a reference to 'component'
84
+ # @param receiver [Prism::Node, nil]
85
+ # @return [Boolean]
86
+ 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
92
+
93
+ # If there is a receiver, it must be a component method call or local variable read
94
+ return false unless receiver.try(:name) == :component
95
+
96
+ receiver.is_a?(Prism::CallNode) || receiver.is_a?(Prism::LocalVariableReadNode)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ module Rendering
5
+ class Handler < ActionView::Template::Handlers::ERB
6
+ def call(template, source)
7
+ parse_result = ::Herb.parse(source, track_whitespace: true)
8
+ ast = parse_result.value
9
+
10
+ engine = Renderer.new
11
+ compiler = Compiler.new(engine)
12
+ ast.accept(compiler)
13
+
14
+ compiler.generate_output
15
+ engine.src
16
+ end
17
+ end
18
+ end
19
+ end