live_cable 0.1.2 → 0.2.1

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.
@@ -5,14 +5,22 @@ module LiveCable
5
5
  module Broadcasting
6
6
  extend ActiveSupport::Concern
7
7
 
8
+ # Nil until the component's own controller subscribes; children rendered
9
+ # by a parent have no channel to deliver to yet.
8
10
  def broadcast(data)
9
- ActionCable.server.broadcast(channel_name, data)
11
+ channel&.broadcast(data)
10
12
  end
11
13
 
12
14
  def broadcast_subscribe
13
15
  broadcast({ _status: 'subscribed', id: live_id })
14
16
  end
15
17
 
18
+ # Sent when a received message didn't change any reactive variables,
19
+ # so the client can clear its loading state without a re-render.
20
+ def broadcast_ack
21
+ broadcast({ _ack: true })
22
+ end
23
+
16
24
  def broadcast_destroy
17
25
  broadcast({ _status: 'destroy' })
18
26
  @subscribed = false
@@ -20,9 +28,14 @@ module LiveCable
20
28
 
21
29
  def broadcast_render
22
30
  run_callbacks :render do
23
- broadcast(
24
- _refresh: render.as_json
25
- )
31
+ data = { _refresh: render.as_json }
32
+
33
+ # Events ride along with the render so the client can fire them
34
+ # after the DOM has been morphed
35
+ events = flush_events
36
+ data[:_events] = events if events.any?
37
+
38
+ broadcast(data)
26
39
  end
27
40
  end
28
41
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ class Component
5
+ module Events
6
+ extend ActiveSupport::Concern
7
+
8
+ # Queue a DOM event to be dispatched on the client. Events are
9
+ # delivered with the next broadcast for this component - attached to
10
+ # the render when state changed, or on their own when it didn't - and
11
+ # fire on the client after the DOM has been morphed, so handlers see
12
+ # the updated markup.
13
+ #
14
+ # On the client the event is a bubbling CustomEvent dispatched from
15
+ # the component's root element (or from window with window: true), so
16
+ # it can be handled with plain Stimulus data-action syntax:
17
+ #
18
+ # <div data-controller="chat" data-action="chat:message-sent->chat#scrollToBottom">
19
+ #
20
+ # @param name [String, Symbol] The event name (e.g. 'chat:message-sent')
21
+ # @param detail [Hash] JSON-serializable payload, available as event.detail.
22
+ # Can be passed positionally or as bare keyword arguments; use the
23
+ # positional form if the payload itself needs a :window key.
24
+ # @param window [Boolean] Dispatch on window instead of the component root
25
+ def dispatch_event(name, positional_detail = nil, window: false, **detail)
26
+ detail = positional_detail if positional_detail
27
+
28
+ pending_events << { name: name.to_s, detail: detail.as_json, window: }
29
+ end
30
+
31
+ # Drain the queued events. Called when a broadcast is sent so each
32
+ # event is delivered exactly once.
33
+ #
34
+ # @return [Array<Hash>]
35
+ def flush_events
36
+ events = pending_events.dup
37
+ pending_events.clear
38
+ events
39
+ end
40
+
41
+ private
42
+
43
+ # @return [Array<Hash>]
44
+ def pending_events
45
+ @pending_events ||= []
46
+ end
47
+ end
48
+ end
49
+ end
@@ -22,10 +22,6 @@ module LiveCable
22
22
  def live_id
23
23
  self.class.component_id(id)
24
24
  end
25
-
26
- def channel_name
27
- "#{live_connection.channel_name}/#{live_id}"
28
- end
29
25
  end
30
26
  end
31
27
  end
@@ -23,7 +23,6 @@ module LiveCable
23
23
  def connect(channel)
24
24
  run_callbacks :connect do
25
25
  @channel = channel
26
- start_stream
27
26
  @subscribed = true
28
27
  end
29
28
  end
@@ -7,14 +7,7 @@ module LiveCable
7
7
 
8
8
  private
9
9
 
10
- def start_stream
11
- channel.stream_from(channel_name)
12
- end
13
-
14
10
  def stop_stream
15
- channel.stop_stream_from(channel_name)
16
-
17
- # Stop all additional streams started via stream_from
18
11
  additional_streams.each do |stream_name|
19
12
  channel.stop_stream_from(stream_name)
20
13
  end
@@ -7,6 +7,7 @@ module LiveCable
7
7
  include Identification
8
8
  include Lifecycle
9
9
  include Broadcasting
10
+ include Events
10
11
  include Rendering
11
12
  include Streaming
12
13
  include MethodDependencyTracking
@@ -5,6 +5,8 @@ module LiveCable
5
5
  module Broadcasting
6
6
  extend ActiveSupport::Concern
7
7
 
8
+ # @return [Array<LiveCable::Component>] Components that were broadcast to
9
+ # (rendered or errored), including children rendered by their parents
8
10
  def broadcast_changeset
9
11
  rendered = []
10
12
  shared_changeset = containers[SHARED_CONTAINER]&.changeset
@@ -25,8 +27,18 @@ module LiveCable
25
27
  handle_error(component, error)
26
28
  end
27
29
 
28
- rendered |= component.rendered_children
30
+ rendered |= [component] | component.rendered_children
29
31
  end
32
+
33
+ # Deliver events from components that didn't broadcast a render this
34
+ # cycle (no state change, or rendered inline by a parent) - rendered
35
+ # components already flushed their events with the refresh
36
+ components.each_value do |component|
37
+ events = component.flush_events
38
+ component.broadcast(_events: events) if events.any?
39
+ end
40
+
41
+ rendered
30
42
  end
31
43
  end
32
44
  end
@@ -11,13 +11,22 @@ module LiveCable
11
11
 
12
12
  return unless data['messages'].present?
13
13
 
14
+ # An error broadcasts an _error, which is itself the batch's one
15
+ # response - so a failed message must suppress the trailing _ack
16
+ errored = false
14
17
  data['messages'].each do |message|
15
- action(component, message)
18
+ errored = true unless action(component, message)
16
19
  end
17
20
 
18
- broadcast_changeset
21
+ rendered = broadcast_changeset
22
+
23
+ # Guarantee exactly one response per message batch so the client can
24
+ # clear its loading state even when nothing changed
25
+ component.broadcast_ack unless errored || rendered.include?(component)
19
26
  end
20
27
 
28
+ # @return [Boolean] true when the message was processed, false when an
29
+ # error was handled (and an _error broadcast in its place)
21
30
  def action(component, data)
22
31
  params = parse_params(data)
23
32
 
@@ -40,18 +49,25 @@ module LiveCable
40
49
  method.call
41
50
  end
42
51
  end
52
+
53
+ true
43
54
  rescue StandardError => e
44
55
  handle_error(component, e)
56
+ false
45
57
  end
46
58
 
59
+ # @return [Boolean] true when applied, false when an error was handled
47
60
  def reactive(component, data)
48
61
  unless component.class.writable_reactive_variables.include?(data['name'].to_sym)
49
62
  raise LiveCable::Error, "Non-writable reactive variable: #{data['name']}"
50
63
  end
51
64
 
52
65
  component.public_send("#{data['name']}=", data['value'])
66
+
67
+ true
53
68
  rescue StandardError => e
54
69
  handle_error(component, e)
70
+ false
55
71
  end
56
72
 
57
73
  private
@@ -3,7 +3,6 @@
3
3
  module LiveCable
4
4
  class Connection
5
5
  include ComponentManagement
6
- include ChannelManagement
7
6
  include StateManagement
8
7
  include Messaging
9
8
  include Broadcasting
@@ -13,17 +12,12 @@ module LiveCable
13
12
 
14
13
  def initialize(request)
15
14
  @request = request
16
- @session_id = SecureRandom.uuid
17
15
  @containers = Hash.new { |hash, key| hash[key] = Container.new }
18
16
  @components = {}
19
- @channels = {}
20
17
  end
21
18
 
22
19
  private
23
20
 
24
- # @return [String]
25
- attr_reader :session_id
26
-
27
21
  # @return [ActionDispatch::Request]
28
22
  attr_reader :request
29
23
 
@@ -3,16 +3,19 @@
3
3
  module LiveCable
4
4
  module Rendering
5
5
  class Compiler < ::Herb::Engine::Compiler
6
+ # Sentinel tokens carry a "\n" value so herb's whitespace helpers
7
+ # (at_line_start?, preceding_token_ends_with_newline?) treat them like
8
+ # a line boundary instead of crashing on a nil value.
6
9
  def visit_erb_control_node(node)
7
- @tokens << [:block_start]
10
+ @tokens << [:block_start, "\n"]
8
11
  super
9
- @tokens << [:block_end]
12
+ @tokens << [:block_end, "\n"]
10
13
  end
11
14
 
12
15
  def visit_erb_block_node(node)
13
- @tokens << [:block_start]
16
+ @tokens << [:block_start, "\n"]
14
17
  super
15
- @tokens << [:block_end]
18
+ @tokens << [:block_end, "\n"]
16
19
  end
17
20
 
18
21
  def generate_output
@@ -29,11 +32,14 @@ module LiveCable
29
32
  end
30
33
  end
31
34
 
32
- def generate_for_token(type, value, context)
35
+ def generate_for_token(type, value, _context = nil, _escaped = nil)
33
36
  case type
34
37
  when :text
35
38
  @engine.send(:add_text, value)
36
- when :code
39
+ when :code, :expr_block_end
40
+ # Escaping is delegated to Rails' output buffer, so the closing
41
+ # `end` of an output block (:expr_block_end) is emitted as plain
42
+ # code rather than herb's paren-balancing add_expression_block_end.
37
43
  @engine.send(:add_code, value)
38
44
  when :expr
39
45
  indicator = @escape ? '==' : '='
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ module Testing
5
+ # Reconstructs a component's rendered HTML from _refresh broadcasts,
6
+ # mirroring what the JavaScript client does: the first refresh carries
7
+ # all parts, subsequent refreshes carry only the changed parts (nil
8
+ # means unchanged), and child components arrive as separate results
9
+ # referenced by <LiveCable child-live-id="..."> placeholders.
10
+ class RenderState
11
+ CHILD_PLACEHOLDER = %r{<LiveCable child-live-id="(?<live_id>[^"]+)"></LiveCable>}
12
+
13
+ def initialize
14
+ @parts_by_template = {}
15
+ @last_template = nil
16
+ @children = Hash.new { |hash, key| hash[key] = RenderState.new }
17
+ end
18
+
19
+ # @param refresh [Hash] A _refresh payload ({ h:, p:, c: })
20
+ def apply(refresh)
21
+ refresh = refresh.as_json # Normalize symbol/string keys
22
+
23
+ template = refresh['h'] || @last_template || 'default'
24
+ @last_template = template
25
+
26
+ parts = refresh['p'] || []
27
+
28
+ if @parts_by_template.key?(template)
29
+ parts.each_with_index do |part, index|
30
+ @parts_by_template[template][index] = part unless part.nil?
31
+ end
32
+ else
33
+ @parts_by_template[template] = parts.dup
34
+ end
35
+
36
+ (refresh['c'] || {}).each do |live_id, child_refresh|
37
+ @children[live_id].apply(child_refresh)
38
+ end
39
+ end
40
+
41
+ # @return [String] The reconstructed HTML with child placeholders resolved
42
+ def html
43
+ parts = @parts_by_template[@last_template]
44
+ return '' unless parts
45
+
46
+ parts.join.gsub(CHILD_PLACEHOLDER) do
47
+ @children[Regexp.last_match[:live_id]].html
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ module Testing
5
+ # Stand-in for an ActionCable connection exposing identified_by values
6
+ # (e.g. current_user) to components and their templates.
7
+ class TestCableConnection
8
+ # @return [Set<Symbol>]
9
+ attr_reader :identifiers
10
+
11
+ def initialize(identifiers = {})
12
+ identifiers = identifiers.symbolize_keys
13
+ @identifiers = identifiers.keys.to_set
14
+
15
+ identifiers.each do |name, value|
16
+ define_singleton_method(name) { value }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ module Testing
5
+ # Stand-in for an ActionCable channel. Records streams started via
6
+ # stream_from so tests can trigger their callbacks with receive_stream,
7
+ # and payloads sent by components.
8
+ class TestChannel
9
+ # @return [Hash<String, Hash>] Stream name => { coder:, callback: }
10
+ attr_reader :streams
11
+
12
+ # @return [Array<Hash>] Payloads sent by components through this channel
13
+ attr_reader :transmissions
14
+
15
+ # @return [LiveCable::Testing::TestCableConnection]
16
+ attr_reader :connection
17
+
18
+ def initialize(identifiers = {})
19
+ @streams = {}
20
+ @transmissions = []
21
+ @connection = TestCableConnection.new(identifiers)
22
+ end
23
+
24
+ def broadcast(data)
25
+ @transmissions << data
26
+ end
27
+
28
+ def stream_from(name, coder: nil, &block)
29
+ @streams[name] = { coder:, callback: block }
30
+ end
31
+
32
+ def stop_stream_from(name)
33
+ @streams.delete(name)
34
+ end
35
+
36
+ # Simulate an external broadcast arriving on a stream.
37
+ # The payload goes through the stream's coder round trip, so a Hash
38
+ # payload arrives with string keys just like a production broadcast.
39
+ #
40
+ # @param name [String] The stream name passed to stream_from
41
+ # @param payload [Object] The broadcast payload
42
+ def broadcast_to(name, payload)
43
+ stream = @streams.fetch(name) do
44
+ raise LiveCable::Error, "Component is not streaming from #{name.inspect} " \
45
+ "(active streams: #{@streams.keys.inspect})"
46
+ end
47
+
48
+ callback = stream[:callback]
49
+ raise LiveCable::Error, "Stream #{name.inspect} has no callback" unless callback
50
+
51
+ coder = stream[:coder]
52
+ payload = coder.decode(coder.encode(payload)) if coder
53
+
54
+ callback.call(payload)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module LiveCable
6
+ module Testing
7
+ # Wraps a mounted component for testing. Delegates unknown methods to
8
+ # the component itself, so reactive variables and component methods can
9
+ # be read directly (e.g. +counter.count+).
10
+ class TestComponent < SimpleDelegator
11
+ # @return [LiveCable::Connection]
12
+ attr_reader :connection
13
+
14
+ # @return [LiveCable::Testing::TestChannel]
15
+ attr_reader :channel
16
+
17
+ def initialize(component, connection, channel)
18
+ super(component)
19
+ @connection = connection
20
+ @channel = channel
21
+ end
22
+
23
+ # @return [LiveCable::Component] The underlying component instance
24
+ def component
25
+ __getobj__
26
+ end
27
+
28
+ # Dispatch an action through the real message pipeline, as if it was
29
+ # triggered by live-action or live-form in the browser.
30
+ #
31
+ # Params go through a query-string round trip, so values arrive as
32
+ # ActionController::Parameters with string values - exactly like
33
+ # production.
34
+ #
35
+ # @param action [Symbol, String] The action name
36
+ # @param params [Hash] Parameters for the action
37
+ def perform(action, params = {})
38
+ receive_message(
39
+ '_action' => action.to_s,
40
+ 'params' => ::Rack::Utils.build_nested_query(params)
41
+ )
42
+ end
43
+
44
+ # Update a writable reactive variable, as if the client sent a
45
+ # live-reactive input update. Raises (or broadcasts an _error when
46
+ # mounted with raise_errors: false) for non-writable variables.
47
+ #
48
+ # @param name [Symbol, String] The reactive variable name
49
+ # @param value [Object] The new value
50
+ def set_reactive(name, value)
51
+ receive_message(
52
+ '_action' => '_reactive',
53
+ 'name' => name.to_s,
54
+ 'value' => value
55
+ )
56
+ end
57
+
58
+ # Simulate an external ActionCable broadcast arriving on a stream the
59
+ # component subscribed to via stream_from.
60
+ #
61
+ # @param stream_name [String] The stream name
62
+ # @param payload [Object] The broadcast payload
63
+ def receive_stream(stream_name, payload)
64
+ channel.broadcast_to(stream_name, payload)
65
+ end
66
+
67
+ # Everything the component has broadcast since mounting (renders,
68
+ # acks, status updates, errors), oldest first.
69
+ #
70
+ # @param key [Symbol, nil] Filter to broadcasts containing this key
71
+ # (e.g. :_refresh, :_ack, :_error, :_status)
72
+ # @return [Array<Hash>]
73
+ def broadcasts(key = nil)
74
+ return channel.transmissions.dup unless key
75
+
76
+ channel.transmissions.select { |broadcast| broadcast.key?(key) }
77
+ end
78
+
79
+ # Forget previously captured broadcasts. Useful after mounting, to
80
+ # assert on the effects of a single action.
81
+ def clear_broadcasts
82
+ channel.transmissions.clear
83
+ end
84
+
85
+ # All events the component has dispatched via dispatch_event, in
86
+ # order, whether they rode along with a render or were broadcast on
87
+ # their own.
88
+ #
89
+ # @return [Array<Hash>] Event hashes ({ name:, detail:, window: })
90
+ def dispatched_events
91
+ broadcasts(:_events).flat_map { |broadcast| broadcast[:_events] }
92
+ end
93
+
94
+ # The component's current HTML, reconstructed from its _refresh
95
+ # broadcasts the same way the JavaScript client builds the DOM.
96
+ #
97
+ # @return [String]
98
+ def rendered_html
99
+ state = RenderState.new
100
+
101
+ broadcasts(:_refresh).each do |broadcast|
102
+ state.apply(broadcast[:_refresh])
103
+ end
104
+
105
+ state.html
106
+ end
107
+
108
+ # The rendered HTML wrapped in a Capybara node, for use with matchers
109
+ # like have_css / have_content. Requires the capybara gem.
110
+ #
111
+ # @return [Capybara::Node::Simple]
112
+ def rendered
113
+ # ::-prefixed because Delegator subclasses can't resolve top-level
114
+ # constants through their BasicObject ancestry
115
+ unless defined?(::Capybara)
116
+ raise ::LiveCable::Error,
117
+ 'Capybara is required for rendered - add it to your Gemfile or use rendered_html'
118
+ end
119
+
120
+ ::Capybara.string(rendered_html)
121
+ end
122
+
123
+ # Disconnect the component, running disconnect lifecycle callbacks and
124
+ # cleaning up its state - like a client unsubscribing.
125
+ def unmount
126
+ component.disconnect
127
+ end
128
+
129
+ private
130
+
131
+ def receive_message(message)
132
+ connection.receive(component, { 'messages' => [message] })
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCable
4
+ # Test helpers for unit testing LiveCable components without a browser
5
+ # or a real ActionCable connection.
6
+ #
7
+ # Include the module in your specs and use +live_mount+ to mount a
8
+ # component. Actions and reactive updates are dispatched through the real
9
+ # message pipeline, so action whitelisting, parameter parsing, writability
10
+ # checks, change tracking, and re-rendering are all exercised exactly as
11
+ # they are in production.
12
+ #
13
+ # @example RSpec
14
+ # RSpec.describe Live::Counter do
15
+ # include LiveCable::Testing
16
+ #
17
+ # it 'increments by the step size' do
18
+ # counter = live_mount('counter', step: 2)
19
+ #
20
+ # counter.perform(:increment)
21
+ #
22
+ # expect(counter.count).to eq(2)
23
+ # expect(counter.rendered).to have_css('[data-testid="counter-value"]', text: '2')
24
+ # end
25
+ # end
26
+ module Testing
27
+ # Mount a component for testing.
28
+ #
29
+ # Mirrors what +LiveChannel#subscribed+ does in production: the component
30
+ # is registered on a connection, defaults are applied, lifecycle connect
31
+ # callbacks run, and the initial render is broadcast.
32
+ #
33
+ # @param component [String, Class, LiveCable::Component] Component name
34
+ # (e.g. 'counter' or 'chat/room'), component class, or instance
35
+ # @param id [String] The component id (defaults to 'test')
36
+ # @param connection [LiveCable::Connection, nil] Mount onto an existing
37
+ # test connection (from another mounted component) to share state
38
+ # between components
39
+ # @param identifiers [Hash] ActionCable connection identifiers made
40
+ # available to the component (e.g. current_user: user)
41
+ # @param raise_errors [Boolean] Raise errors from actions and rendering
42
+ # instead of broadcasting an _error like production does (default true)
43
+ # @param defaults [Hash] Default values for reactive variables
44
+ # @return [LiveCable::Testing::TestComponent]
45
+ def live_mount(component, id: 'test', connection: nil, identifiers: {}, raise_errors: true, **defaults)
46
+ connection ||= build_test_connection(raise_errors:)
47
+
48
+ instance =
49
+ case component
50
+ when LiveCable::Component then component
51
+ when Class then component.new(id)
52
+ else LiveCable.instance_from_string(component.to_s, id)
53
+ end
54
+
55
+ test_component = TestComponent.new(instance, connection, TestChannel.new(identifiers))
56
+
57
+ connection.add_component(instance)
58
+ instance.defaults = defaults
59
+ instance.apply_defaults
60
+ instance.connect(test_component.channel)
61
+ instance.broadcast_render
62
+
63
+ test_component
64
+ end
65
+
66
+ private
67
+
68
+ def build_test_connection(raise_errors:)
69
+ require 'action_dispatch/testing/test_request'
70
+
71
+ # An empty session skips the CSRF check, like a session-less request
72
+ request = ActionDispatch::TestRequest.create('rack.session' => {})
73
+ connection = LiveCable::Connection.new(request)
74
+
75
+ if raise_errors
76
+ def connection.handle_error(_component, error)
77
+ raise error
78
+ end
79
+ end
80
+
81
+ connection
82
+ end
83
+ end
84
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LiveCable
4
- VERSION = '0.1.2'
4
+ VERSION = '0.2.1'
5
5
  end
data/lib/live_cable.rb CHANGED
@@ -5,6 +5,7 @@ require 'herb'
5
5
 
6
6
  loader = Zeitwerk::Loader.for_gem
7
7
  loader.ignore("#{__dir__}/generators")
8
+ loader.ignore("#{__dir__}/live.rb")
8
9
  loader.setup
9
10
 
10
11
  require_relative 'live_cable/configuration'
@@ -12,6 +13,11 @@ require_relative 'live_cable/configuration'
12
13
  # Require helpers explicitly (Zeitwerk doesn't autoload app/ directory)
13
14
  require_relative '../app/helpers/live_cable_helper'
14
15
 
16
+ # Namespace for user components (e.g. Live::Chat); lives outside the gem's
17
+ # own LiveCable namespace, so it's ignored by the gem loader above and
18
+ # required explicitly instead.
19
+ require_relative 'live'
20
+
15
21
  module LiveCable
16
22
  def self.instance_from_string(string, id)
17
23
  klass = Live
@@ -40,8 +46,4 @@ module LiveCable
40
46
  end
41
47
  end
42
48
 
43
- module Live
44
- # For components to live in
45
- end
46
-
47
49
  require 'live_cable/engine' if defined?(Rails::Engine)