live_cable 0.1.1 → 0.2.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,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
@@ -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
@@ -12,8 +12,10 @@ module LiveCable
12
12
  warn("[LiveCable Warning] #{live_component_dir} does not exist for components.")
13
13
  end
14
14
 
15
- # Add LiveCable to importmap
16
- app.config.importmap.paths << root.join('config/importmap.rb')
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
17
19
  end
18
20
 
19
21
  initializer 'live_cable.assets_precompile' do |app|
@@ -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,49 @@
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
+ class TestChannel
8
+ # @return [Hash<String, Hash>] Stream name => { coder:, callback: }
9
+ attr_reader :streams
10
+
11
+ # @return [LiveCable::Testing::TestCableConnection]
12
+ attr_reader :connection
13
+
14
+ def initialize(identifiers = {})
15
+ @streams = {}
16
+ @connection = TestCableConnection.new(identifiers)
17
+ end
18
+
19
+ def stream_from(name, coder: nil, &block)
20
+ @streams[name] = { coder:, callback: block }
21
+ end
22
+
23
+ def stop_stream_from(name)
24
+ @streams.delete(name)
25
+ end
26
+
27
+ # Simulate an external broadcast arriving on a stream.
28
+ # The payload goes through the stream's coder round trip, so a Hash
29
+ # payload arrives with string keys just like a production broadcast.
30
+ #
31
+ # @param name [String] The stream name passed to stream_from
32
+ # @param payload [Object] The broadcast payload
33
+ def broadcast_to(name, payload)
34
+ stream = @streams.fetch(name) do
35
+ raise LiveCable::Error, "Component is not streaming from #{name.inspect} " \
36
+ "(active streams: #{@streams.keys.inspect})"
37
+ end
38
+
39
+ callback = stream[:callback]
40
+ raise LiveCable::Error, "Stream #{name.inspect} has no callback" unless callback
41
+
42
+ coder = stream[:coder]
43
+ payload = coder.decode(coder.encode(payload)) if coder
44
+
45
+ callback.call(payload)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,147 @@
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
+ @broadcasts = []
22
+
23
+ capture_broadcasts(component)
24
+ end
25
+
26
+ # @return [LiveCable::Component] The underlying component instance
27
+ def component
28
+ __getobj__
29
+ end
30
+
31
+ # Dispatch an action through the real message pipeline, as if it was
32
+ # triggered by live-action or live-form in the browser.
33
+ #
34
+ # Params go through a query-string round trip, so values arrive as
35
+ # ActionController::Parameters with string values - exactly like
36
+ # production.
37
+ #
38
+ # @param action [Symbol, String] The action name
39
+ # @param params [Hash] Parameters for the action
40
+ def perform(action, params = {})
41
+ receive_message(
42
+ '_action' => action.to_s,
43
+ 'params' => ::Rack::Utils.build_nested_query(params)
44
+ )
45
+ end
46
+
47
+ # Update a writable reactive variable, as if the client sent a
48
+ # live-reactive input update. Raises (or broadcasts an _error when
49
+ # mounted with raise_errors: false) for non-writable variables.
50
+ #
51
+ # @param name [Symbol, String] The reactive variable name
52
+ # @param value [Object] The new value
53
+ def set_reactive(name, value)
54
+ receive_message(
55
+ '_action' => '_reactive',
56
+ 'name' => name.to_s,
57
+ 'value' => value
58
+ )
59
+ end
60
+
61
+ # Simulate an external ActionCable broadcast arriving on a stream the
62
+ # component subscribed to via stream_from.
63
+ #
64
+ # @param stream_name [String] The stream name
65
+ # @param payload [Object] The broadcast payload
66
+ def receive_stream(stream_name, payload)
67
+ channel.broadcast_to(stream_name, payload)
68
+ end
69
+
70
+ # Everything the component has broadcast since mounting (renders,
71
+ # acks, status updates, errors), oldest first.
72
+ #
73
+ # @param key [Symbol, nil] Filter to broadcasts containing this key
74
+ # (e.g. :_refresh, :_ack, :_error, :_status)
75
+ # @return [Array<Hash>]
76
+ def broadcasts(key = nil)
77
+ return @broadcasts.dup unless key
78
+
79
+ @broadcasts.select { |broadcast| broadcast.key?(key) }
80
+ end
81
+
82
+ # Forget previously captured broadcasts. Useful after mounting, to
83
+ # assert on the effects of a single action.
84
+ def clear_broadcasts
85
+ @broadcasts.clear
86
+ end
87
+
88
+ # All events the component has dispatched via dispatch_event, in
89
+ # order, whether they rode along with a render or were broadcast on
90
+ # their own.
91
+ #
92
+ # @return [Array<Hash>] Event hashes ({ name:, detail:, window: })
93
+ def dispatched_events
94
+ broadcasts(:_events).flat_map { |broadcast| broadcast[:_events] }
95
+ end
96
+
97
+ # The component's current HTML, reconstructed from its _refresh
98
+ # broadcasts the same way the JavaScript client builds the DOM.
99
+ #
100
+ # @return [String]
101
+ def rendered_html
102
+ state = RenderState.new
103
+
104
+ broadcasts(:_refresh).each do |broadcast|
105
+ state.apply(broadcast[:_refresh])
106
+ end
107
+
108
+ state.html
109
+ end
110
+
111
+ # The rendered HTML wrapped in a Capybara node, for use with matchers
112
+ # like have_css / have_content. Requires the capybara gem.
113
+ #
114
+ # @return [Capybara::Node::Simple]
115
+ def rendered
116
+ # ::-prefixed because Delegator subclasses can't resolve top-level
117
+ # constants through their BasicObject ancestry
118
+ unless defined?(::Capybara)
119
+ raise ::LiveCable::Error,
120
+ 'Capybara is required for rendered - add it to your Gemfile or use rendered_html'
121
+ end
122
+
123
+ ::Capybara.string(rendered_html)
124
+ end
125
+
126
+ # Disconnect the component, running disconnect lifecycle callbacks and
127
+ # cleaning up its state - like a client unsubscribing.
128
+ def unmount
129
+ component.disconnect
130
+ end
131
+
132
+ private
133
+
134
+ def receive_message(message)
135
+ connection.receive(component, { 'messages' => [message] })
136
+ end
137
+
138
+ def capture_broadcasts(component)
139
+ captured = @broadcasts
140
+
141
+ component.define_singleton_method(:broadcast) do |data|
142
+ captured << data
143
+ end
144
+ end
145
+ end
146
+ end
147
+ 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.1'
4
+ VERSION = '0.2.0'
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)