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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +567 -0
- data/README.md +14 -2
- data/app/assets/javascript/controllers/live_controller.js +51 -7
- data/app/assets/javascript/live_cable.js +2 -0
- data/app/assets/javascript/loading.js +58 -6
- data/app/assets/javascript/subscriptions.js +292 -22
- data/app/channels/live_channel.rb +51 -29
- data/app/helpers/live_cable_helper.rb +31 -2
- data/lib/live_cable/action_cable_connection.rb +4 -1
- data/lib/live_cable/component/broadcasting.rb +37 -6
- data/lib/live_cable/component/dispatching.rb +36 -0
- data/lib/live_cable/component/lifecycle.rb +1 -1
- data/lib/live_cable/component/reactive_variables.rb +33 -5
- data/lib/live_cable/component/rendering.rb +30 -14
- data/lib/live_cable/component/streaming.rb +35 -4
- data/lib/live_cable/component.rb +4 -3
- data/lib/live_cable/connection/broadcasting.rb +15 -3
- data/lib/live_cable/connection/component_management.rb +12 -8
- data/lib/live_cable/connection/error_handling.rb +28 -7
- data/lib/live_cable/connection/messaging.rb +40 -52
- data/lib/live_cable/connection/state_management.rb +13 -16
- data/lib/live_cable/connection.rb +33 -0
- data/lib/live_cable/container.rb +17 -11
- data/lib/live_cable/defaults_signer.rb +95 -0
- data/lib/live_cable/delegation/array.rb +27 -1
- data/lib/live_cable/delegation/hash.rb +55 -1
- data/lib/live_cable/delegation/methods.rb +25 -1
- data/lib/live_cable/delegation/model.rb +12 -0
- data/lib/live_cable/delegator.rb +54 -6
- data/lib/live_cable/dispatch.rb +54 -0
- data/lib/live_cable/engine.rb +3 -1
- data/lib/live_cable/forbidden.rb +7 -0
- data/lib/live_cable/identity_digest.rb +27 -0
- data/lib/live_cable/model_observer.rb +37 -0
- data/lib/live_cable/observer_tracking.rb +10 -1
- data/lib/live_cable/predicate_builder.rb +13 -0
- data/lib/live_cable/render_context.rb +82 -8
- data/lib/live_cable/rendering/controller_renderer.rb +26 -0
- data/lib/live_cable/rendering/dependency_visitor.rb +68 -24
- data/lib/live_cable/rendering/handler.rb +1 -1
- data/lib/live_cable/rendering/method_analyzer.rb +18 -4
- data/lib/live_cable/rendering/method_collector.rb +13 -5
- data/lib/live_cable/rendering/partial.rb +13 -1
- data/lib/live_cable/rendering/partial_renderer.rb +59 -7
- data/lib/live_cable/rendering/render_result.rb +5 -0
- data/lib/live_cable/rendering/renderer.rb +18 -14
- data/lib/live_cable/rendering/view_buffer.rb +21 -0
- data/lib/live_cable/testing/render_state.rb +5 -3
- data/lib/live_cable/testing/test_channel.rb +6 -0
- data/lib/live_cable/testing/test_component.rb +3 -3
- data/lib/live_cable/testing.rb +9 -5
- data/lib/live_cable/version.rb +1 -1
- data/lib/live_cable.rb +32 -3
- metadata +9 -1
|
@@ -12,11 +12,24 @@ module LiveCable
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def broadcast_subscribe
|
|
15
|
-
broadcast({ _status: 'subscribed', id: live_id })
|
|
15
|
+
broadcast({ _status: 'subscribed', id: live_id, _subscribed: true })
|
|
16
|
+
|
|
17
|
+
# Deliver any events queued before this component had a channel of its
|
|
18
|
+
# own (e.g. dispatched while it was rendered inline by a parent). The
|
|
19
|
+
# broadcast_render path flushes events itself, so this only matters for
|
|
20
|
+
# an already-rendered component that subscribes without re-rendering.
|
|
21
|
+
broadcast_events
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Sends any queued events on their own, for when there's no render for
|
|
25
|
+
# them to ride along with.
|
|
26
|
+
def broadcast_events
|
|
27
|
+
events = flush_events
|
|
28
|
+
broadcast(_events: events) if events.any?
|
|
16
29
|
end
|
|
17
30
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
31
|
+
# Answers a message batch when the component's own render didn't -
|
|
32
|
+
# nothing changed, or its render went out inside its parent's.
|
|
20
33
|
def broadcast_ack
|
|
21
34
|
broadcast({ _ack: true })
|
|
22
35
|
end
|
|
@@ -26,17 +39,35 @@ module LiveCable
|
|
|
26
39
|
@subscribed = false
|
|
27
40
|
end
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
# @param subscribed [Boolean] whether this is the render sent when the
|
|
43
|
+
# component subscribes
|
|
44
|
+
# @return [Boolean] false when the render changed nothing on the page, so
|
|
45
|
+
# no _refresh went out
|
|
46
|
+
def broadcast_render(subscribed: false)
|
|
47
|
+
sent = false
|
|
48
|
+
|
|
30
49
|
run_callbacks :render do
|
|
31
|
-
|
|
50
|
+
result = render
|
|
51
|
+
events = flush_events
|
|
52
|
+
|
|
53
|
+
# Leaves the reply unclaimed, so the message is answered with an _ack
|
|
54
|
+
if !subscribed && result.is_a?(LiveCable::Rendering::RenderResult) && result.blank?
|
|
55
|
+
broadcast(_events: events) if events.any?
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
data = { _refresh: result.as_json, _reply: live_connection&.take_reply(self) }
|
|
60
|
+
data[:_subscribed] = true if subscribed
|
|
32
61
|
|
|
33
62
|
# Events ride along with the render so the client can fire them
|
|
34
63
|
# after the DOM has been morphed
|
|
35
|
-
events = flush_events
|
|
36
64
|
data[:_events] = events if events.any?
|
|
37
65
|
|
|
38
66
|
broadcast(data)
|
|
67
|
+
sent = true
|
|
39
68
|
end
|
|
69
|
+
|
|
70
|
+
sent
|
|
40
71
|
end
|
|
41
72
|
end
|
|
42
73
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LiveCable
|
|
4
|
+
class Component
|
|
5
|
+
module Dispatching
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
define_model_callbacks :dispatch, only: :before
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @return [LiveCable::Dispatch, nil] The client message being dispatched
|
|
13
|
+
attr_reader :current_dispatch
|
|
14
|
+
|
|
15
|
+
# Run the before_dispatch callbacks, then the action or reactive write
|
|
16
|
+
# the message asks for, unless a callback threw :abort.
|
|
17
|
+
#
|
|
18
|
+
# @param dispatch [LiveCable::Dispatch] Already checked against the
|
|
19
|
+
# component's actions and writable variables
|
|
20
|
+
def perform_dispatch(dispatch)
|
|
21
|
+
@current_dispatch = dispatch
|
|
22
|
+
|
|
23
|
+
run_callbacks :dispatch do
|
|
24
|
+
if dispatch.reactive?
|
|
25
|
+
public_send("#{dispatch.name}=", dispatch.value)
|
|
26
|
+
else
|
|
27
|
+
action = method(dispatch.name)
|
|
28
|
+
action.arity.positive? ? action.call(dispatch.params) : action.call
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
ensure
|
|
32
|
+
@current_dispatch = nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -5,6 +5,10 @@ module LiveCable
|
|
|
5
5
|
module ReactiveVariables
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
+
# A client may write a shared name only if every class sharing it declares
|
|
9
|
+
# it writable. Keyed by class name so a reload replaces the entry.
|
|
10
|
+
SHARED_DECLARATIONS = Concurrent::Map.new
|
|
11
|
+
|
|
8
12
|
included do
|
|
9
13
|
class_attribute :shared_variables, default: []
|
|
10
14
|
class_attribute :reactive_variables, default: []
|
|
@@ -29,12 +33,22 @@ module LiveCable
|
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
create_reactive_variables(variable, initial_value, shared: shared)
|
|
36
|
+
declare_shared(variable, writable:) if shared
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
def shared(variable, initial_value = nil)
|
|
35
40
|
self.shared_variables = (shared_variables || []).dup << variable
|
|
36
41
|
|
|
37
42
|
create_reactive_variables(variable, initial_value, shared: true)
|
|
43
|
+
declare_shared(variable, writable: false)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Boolean] whether a client message may set the variable
|
|
47
|
+
def client_writable?(variable)
|
|
48
|
+
return false unless writable_reactive_variables.include?(variable)
|
|
49
|
+
return true unless shared_reactive_variables.include?(variable)
|
|
50
|
+
|
|
51
|
+
SHARED_DECLARATIONS.fetch(variable, {}).values.all?
|
|
38
52
|
end
|
|
39
53
|
|
|
40
54
|
def actions(*names)
|
|
@@ -48,7 +62,15 @@ module LiveCable
|
|
|
48
62
|
|
|
49
63
|
private
|
|
50
64
|
|
|
65
|
+
def declare_shared(variable, writable:)
|
|
66
|
+
SHARED_DECLARATIONS.compute_if_absent(variable) { Concurrent::Map.new }[name || self] = writable
|
|
67
|
+
end
|
|
68
|
+
|
|
51
69
|
def create_reactive_variables(variable, initial_value, shared: false)
|
|
70
|
+
unless initial_value.nil? || initial_value.is_a?(Proc)
|
|
71
|
+
raise ArgumentError, "Initial value for :#{variable} must be a proc or nil"
|
|
72
|
+
end
|
|
73
|
+
|
|
52
74
|
define_method(variable) do
|
|
53
75
|
container_name = shared ? Connection::SHARED_CONTAINER : live_id
|
|
54
76
|
|
|
@@ -60,11 +82,12 @@ module LiveCable
|
|
|
60
82
|
|
|
61
83
|
return if initial_value.nil?
|
|
62
84
|
|
|
63
|
-
|
|
64
|
-
initial_value.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
prerender_container[variable] =
|
|
86
|
+
if initial_value.arity.positive?
|
|
87
|
+
initial_value.call(self)
|
|
88
|
+
else
|
|
89
|
+
initial_value.call
|
|
90
|
+
end
|
|
68
91
|
end
|
|
69
92
|
|
|
70
93
|
define_method("#{variable}=") do |value|
|
|
@@ -99,6 +122,11 @@ module LiveCable
|
|
|
99
122
|
@defaults = (defaults || {}).symbolize_keys
|
|
100
123
|
end
|
|
101
124
|
|
|
125
|
+
# @api private
|
|
126
|
+
def round_trip_defaults
|
|
127
|
+
self.defaults = DefaultsSigner.round_trip(defaults, self.class)
|
|
128
|
+
end
|
|
129
|
+
|
|
102
130
|
def apply_defaults
|
|
103
131
|
# Don't set defaults more than once
|
|
104
132
|
return if defaults_applied
|
|
@@ -17,6 +17,8 @@ module LiveCable
|
|
|
17
17
|
|
|
18
18
|
def render
|
|
19
19
|
@rendered = true
|
|
20
|
+
return render_in(live_connection.view_context) if live_connection
|
|
21
|
+
|
|
20
22
|
ApplicationController.renderer.render(self, layout: false)
|
|
21
23
|
end
|
|
22
24
|
|
|
@@ -38,13 +40,15 @@ module LiveCable
|
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def render_in(view_context)
|
|
43
|
+
template_changed = false
|
|
44
|
+
|
|
41
45
|
view, render_context = view_context.with_render_context(self) do
|
|
42
|
-
result = view_context.render(template: to_partial_path, locals:)
|
|
46
|
+
result = view_context.render(template: to_partial_path, locals: locals(view_context))
|
|
43
47
|
|
|
44
48
|
unless result.is_a?(LiveCable::Rendering::Partial)
|
|
45
|
-
|
|
46
|
-
"[LiveCable
|
|
47
|
-
'
|
|
49
|
+
LiveCable.warn_once(
|
|
50
|
+
"[LiveCable] #{to_partial_path} was rendered without a .live.erb template; " \
|
|
51
|
+
'the full template diff is sent on every change, which is less performant.'
|
|
48
52
|
)
|
|
49
53
|
|
|
50
54
|
next result
|
|
@@ -87,10 +91,8 @@ module LiveCable
|
|
|
87
91
|
end
|
|
88
92
|
|
|
89
93
|
if previous_render_context
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
preserved = render_context.preserved_children_from(previous_render_context)
|
|
93
|
-
destroyed = previous_render_context.children - render_context.children - preserved
|
|
94
|
+
render_context.inherit_skipped(previous_render_context) unless template_changed
|
|
95
|
+
destroyed = previous_render_context.owned_children - render_context.owned_children
|
|
94
96
|
destroyed.each(&:destroy)
|
|
95
97
|
end
|
|
96
98
|
|
|
@@ -130,6 +132,11 @@ module LiveCable
|
|
|
130
132
|
previous_render_context&.children || []
|
|
131
133
|
end
|
|
132
134
|
|
|
135
|
+
# @return [Array<LiveCable::Component>]
|
|
136
|
+
def owned_children
|
|
137
|
+
previous_render_context&.owned_children || []
|
|
138
|
+
end
|
|
139
|
+
|
|
133
140
|
private
|
|
134
141
|
|
|
135
142
|
# @return [Hash<String, Boolean>]
|
|
@@ -145,9 +152,10 @@ module LiveCable
|
|
|
145
152
|
matches = html.match(/(?:\n\s*|^\s*|<!--.*?-->)<([a-zA-Z0-9-]+)/)
|
|
146
153
|
|
|
147
154
|
unless matches
|
|
155
|
+
preview = html.to_s.strip[0, 80]
|
|
148
156
|
raise LiveCable::Error,
|
|
149
157
|
"#{self.class.name} template must have a single root HTML element " \
|
|
150
|
-
|
|
158
|
+
"(could not find an opening tag in the first part of rendered output: #{preview.inspect})"
|
|
151
159
|
end
|
|
152
160
|
|
|
153
161
|
attributes = {
|
|
@@ -157,17 +165,25 @@ module LiveCable
|
|
|
157
165
|
'data-live-status-value' => subscribed? ? 'subscribed' : 'disconnected',
|
|
158
166
|
}
|
|
159
167
|
|
|
160
|
-
|
|
168
|
+
# Sign the defaults so the client can't tamper with them on the round
|
|
169
|
+
# trip and set non-writable reactive variables at subscribe time.
|
|
170
|
+
unless live_connection
|
|
171
|
+
attributes['live-defaults'] = LiveCable::DefaultsSigner.sign(defaults, live_id)
|
|
172
|
+
end
|
|
161
173
|
|
|
162
|
-
html
|
|
163
|
-
|
|
174
|
+
# Build a new string rather than mutating `html` in place with
|
|
175
|
+
# String#insert - the rendered part may be frozen, which would raise
|
|
176
|
+
# a FrozenError.
|
|
177
|
+
insert_at = matches.end(1)
|
|
178
|
+
rendered_attributes = " #{view_context.tag.attributes(attributes)}"
|
|
179
|
+
"#{html[0...insert_at]}#{rendered_attributes}#{html[insert_at..]}".html_safe
|
|
164
180
|
end
|
|
165
181
|
|
|
166
|
-
def locals
|
|
182
|
+
def locals(view_context)
|
|
167
183
|
identifiers = channel ? channel.connection.identifiers.to_a : []
|
|
168
184
|
|
|
169
185
|
(all_reactive_variables | (self.class.shared_variables || []) | identifiers).
|
|
170
|
-
to_h { |v| [v, public_send(v)] }.
|
|
186
|
+
to_h { |v| [v, view_context.live_cable_unwrap(public_send(v))] }.
|
|
171
187
|
merge(
|
|
172
188
|
component: self
|
|
173
189
|
)
|
|
@@ -11,6 +11,13 @@ module LiveCable
|
|
|
11
11
|
additional_streams.each do |stream_name|
|
|
12
12
|
channel.stop_stream_from(stream_name)
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
additional_streams.clear
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def stop_stream_from(channel_name)
|
|
19
|
+
channel.stop_stream_from(channel_name)
|
|
20
|
+
additional_streams.delete(channel_name)
|
|
14
21
|
end
|
|
15
22
|
|
|
16
23
|
def stream_from(channel_name, callback = nil, coder: nil, &block)
|
|
@@ -19,11 +26,35 @@ module LiveCable
|
|
|
19
26
|
channel.stream_from(channel_name, coder:) do |payload|
|
|
20
27
|
callback ||= block
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
# Stopping a stream, as disconnect does, can't take back a broadcast
|
|
30
|
+
# already queued, which may run after it or wait on the lock below
|
|
31
|
+
connection = live_connection
|
|
32
|
+
next unless connection
|
|
33
|
+
|
|
34
|
+
# Held across the callback and the render together: the callback's
|
|
35
|
+
# writes are what race an action on another worker, so locking only
|
|
36
|
+
# the broadcast would leave the race in place.
|
|
37
|
+
connection.synchronize do
|
|
38
|
+
next unless live_connection == connection
|
|
39
|
+
next unless additional_streams.include?(channel_name)
|
|
40
|
+
|
|
41
|
+
connection.reset_changeset
|
|
42
|
+
|
|
43
|
+
# A rescue_from handler that takes the error may have changed state
|
|
44
|
+
# to show it, so render in that case too; after an _error there is
|
|
45
|
+
# nothing left to render into.
|
|
46
|
+
handled = begin
|
|
47
|
+
callback.call(payload)
|
|
48
|
+
true
|
|
49
|
+
rescue StandardError => error
|
|
50
|
+
connection.rescue_error(self, error)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
connection.broadcast_changeset if handled
|
|
54
|
+
# Nothing above this rescues a stream callback, so a raising
|
|
55
|
+
# rescue_from handler or render must not escape to ActionCable
|
|
25
56
|
rescue StandardError => error
|
|
26
|
-
|
|
57
|
+
connection.handle_error(self, error)
|
|
27
58
|
end
|
|
28
59
|
end
|
|
29
60
|
end
|
data/lib/live_cable/component.rb
CHANGED
|
@@ -6,6 +6,7 @@ module LiveCable
|
|
|
6
6
|
include ReactiveVariables
|
|
7
7
|
include Identification
|
|
8
8
|
include Lifecycle
|
|
9
|
+
include Dispatching
|
|
9
10
|
include Broadcasting
|
|
10
11
|
include Events
|
|
11
12
|
include Rendering
|
|
@@ -17,6 +18,9 @@ module LiveCable
|
|
|
17
18
|
# @return [String]
|
|
18
19
|
attr_reader :id
|
|
19
20
|
|
|
21
|
+
# @return [Hash]
|
|
22
|
+
attr_reader :defaults
|
|
23
|
+
|
|
20
24
|
def initialize(id, **defaults)
|
|
21
25
|
@id = self.class.resolve_id(id)
|
|
22
26
|
@rendered = false
|
|
@@ -32,9 +36,6 @@ module LiveCable
|
|
|
32
36
|
# @return [Boolean]
|
|
33
37
|
attr_reader :subscribed
|
|
34
38
|
|
|
35
|
-
# @return [Hash]
|
|
36
|
-
attr_reader :defaults
|
|
37
|
-
|
|
38
39
|
# @return [LiveCable::Channel, nil]
|
|
39
40
|
attr_reader :channel
|
|
40
41
|
end
|
|
@@ -8,6 +8,12 @@ module LiveCable
|
|
|
8
8
|
# @return [Array<LiveCable::Component>] Components that were broadcast to
|
|
9
9
|
# (rendered or errored), including children rendered by their parents
|
|
10
10
|
def broadcast_changeset
|
|
11
|
+
synchronize { broadcast_changeset_unsynchronized }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def broadcast_changeset_unsynchronized
|
|
11
17
|
rendered = []
|
|
12
18
|
shared_changeset = containers[SHARED_CONTAINER]&.changeset
|
|
13
19
|
|
|
@@ -32,10 +38,16 @@ module LiveCable
|
|
|
32
38
|
|
|
33
39
|
# Deliver events from components that didn't broadcast a render this
|
|
34
40
|
# cycle (no state change, or rendered inline by a parent) - rendered
|
|
35
|
-
# components already flushed their events with the refresh
|
|
41
|
+
# components already flushed their events with the refresh.
|
|
42
|
+
#
|
|
43
|
+
# A component rendered inline by a parent has no channel of its own
|
|
44
|
+
# yet, so it can't deliver anything. Leave its events queued (don't
|
|
45
|
+
# flush) so they're delivered when its own subscription connects,
|
|
46
|
+
# rather than silently dropped here.
|
|
36
47
|
components.each_value do |component|
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
next unless component.subscribed?
|
|
49
|
+
|
|
50
|
+
component.broadcast_events
|
|
39
51
|
end
|
|
40
52
|
|
|
41
53
|
rendered
|
|
@@ -10,19 +10,23 @@ module LiveCable
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def add_component(component)
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
synchronize do
|
|
14
|
+
component.live_connection = self
|
|
15
|
+
components[component.live_id] = component
|
|
16
|
+
end
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def remove_component(component)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
synchronize do
|
|
21
|
+
# Clean up the container to break observer reference chains
|
|
22
|
+
container = containers.delete(component.live_id)
|
|
23
|
+
container&.cleanup
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
components.delete(component.live_id)
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
# Clean up shared container if no components remain
|
|
28
|
+
cleanup_shared_container
|
|
29
|
+
end
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
private
|
|
@@ -5,6 +5,24 @@ module LiveCable
|
|
|
5
5
|
module ErrorHandling
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
|
+
# Offer an error raised by the component's own code - an action, a
|
|
9
|
+
# reactive write, a stream callback - to its rescue_from handlers, and
|
|
10
|
+
# fall back to #handle_error when none matches.
|
|
11
|
+
#
|
|
12
|
+
# A handled error is a processed message, not a failed one: no _error
|
|
13
|
+
# goes out, so the caller must still answer with a render or an _ack or
|
|
14
|
+
# the client's loading state never clears.
|
|
15
|
+
#
|
|
16
|
+
# @param component [LiveCable::Component]
|
|
17
|
+
# @param error [Exception]
|
|
18
|
+
# @return [Boolean] true when a handler took the error
|
|
19
|
+
def rescue_error(component, error)
|
|
20
|
+
return true if component.rescue_with_handler(error)
|
|
21
|
+
|
|
22
|
+
handle_error(component, error)
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
8
26
|
# Report an error and replace the component on the client with an
|
|
9
27
|
# error box.
|
|
10
28
|
#
|
|
@@ -16,16 +34,19 @@ module LiveCable
|
|
|
16
34
|
# through; needed when the component failed before it connected to
|
|
17
35
|
# one, or when there is no component at all
|
|
18
36
|
def handle_error(component, error, channel: nil)
|
|
19
|
-
|
|
37
|
+
synchronize do
|
|
38
|
+
take_reply(component)
|
|
39
|
+
Rails.error.report(error)
|
|
20
40
|
|
|
21
|
-
|
|
41
|
+
html = error_html(component, error)
|
|
22
42
|
|
|
23
|
-
|
|
24
|
-
|
|
43
|
+
# Destroy children first so their _status:destroy messages arrive before _error
|
|
44
|
+
component&.owned_children&.each(&:destroy)
|
|
25
45
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
46
|
+
# Broadcast the error - JS replaces the DOM and calls unsubscribe(),
|
|
47
|
+
# which triggers LiveChannel#unsubscribed -> component.disconnect for server cleanup
|
|
48
|
+
(channel || component)&.broadcast(_error: html)
|
|
49
|
+
end
|
|
29
50
|
end
|
|
30
51
|
|
|
31
52
|
private
|
|
@@ -6,79 +6,67 @@ module LiveCable
|
|
|
6
6
|
extend ActiveSupport::Concern
|
|
7
7
|
|
|
8
8
|
def receive(component, data)
|
|
9
|
-
|
|
9
|
+
synchronize do
|
|
10
|
+
reset_changeset
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
return unless data['messages'].present?
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
# response - so a failed message must suppress the trailing _ack
|
|
15
|
-
errored = false
|
|
16
|
-
data['messages'].each do |message|
|
|
17
|
-
errored = true unless action(component, message)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
rendered = broadcast_changeset
|
|
21
|
-
|
|
22
|
-
# Guarantee exactly one response per message batch so the client can
|
|
23
|
-
# clear its loading state even when nothing changed
|
|
24
|
-
component.broadcast_ack unless errored || rendered.include?(component)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# @return [Boolean] true when the message was processed, false when an
|
|
28
|
-
# error was handled (and an _error broadcast in its place)
|
|
29
|
-
def action(component, data)
|
|
30
|
-
params = parse_params(data)
|
|
14
|
+
@reply_to = component
|
|
31
15
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if action == :_reactive
|
|
36
|
-
return reactive(component, data)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
unless component.class.allowed_actions.include?(action)
|
|
40
|
-
raise LiveCable::Error, "Unauthorized action: #{action}"
|
|
16
|
+
data['messages'].each do |message|
|
|
17
|
+
action(component, message)
|
|
41
18
|
end
|
|
42
19
|
|
|
43
|
-
|
|
20
|
+
broadcast_changeset
|
|
44
21
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
22
|
+
# Guarantee exactly one reply per message batch so the client can
|
|
23
|
+
# clear its loading state: the component's own render, an _error,
|
|
24
|
+
# or this
|
|
25
|
+
component.broadcast_ack if take_reply(component)
|
|
26
|
+
ensure
|
|
27
|
+
@reply_to = nil
|
|
50
28
|
end
|
|
29
|
+
end
|
|
51
30
|
|
|
31
|
+
# @return [Boolean] true, once, for the frame that answers the message
|
|
32
|
+
# batch the component sent
|
|
33
|
+
def take_reply(component)
|
|
34
|
+
return false unless component && @reply_to.equal?(component)
|
|
35
|
+
|
|
36
|
+
@reply_to = nil
|
|
52
37
|
true
|
|
53
|
-
rescue StandardError => e
|
|
54
|
-
handle_error(component, e)
|
|
55
|
-
false
|
|
56
38
|
end
|
|
57
39
|
|
|
58
|
-
# @return [Boolean] true when
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
40
|
+
# @return [Boolean] true when the message was processed (including one a
|
|
41
|
+
# before_dispatch callback halted, or whose error the component's
|
|
42
|
+
# rescue_from took), false when an _error was broadcast in its place
|
|
43
|
+
def action(component, data)
|
|
44
|
+
return true unless data['_action']
|
|
63
45
|
|
|
64
|
-
|
|
46
|
+
dispatch = Dispatch.from_message(data)
|
|
47
|
+
ensure_exposed(component, dispatch)
|
|
48
|
+
component.perform_dispatch(dispatch)
|
|
65
49
|
|
|
66
50
|
true
|
|
67
|
-
rescue
|
|
51
|
+
rescue LiveCable::Forbidden => e
|
|
68
52
|
handle_error(component, e)
|
|
69
53
|
false
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
rescue_error(component, e)
|
|
70
56
|
end
|
|
71
57
|
|
|
72
58
|
private
|
|
73
59
|
|
|
74
|
-
def
|
|
75
|
-
|
|
60
|
+
def ensure_exposed(component, dispatch)
|
|
61
|
+
if dispatch.reactive?
|
|
62
|
+
return if component.class.client_writable?(dispatch.name)
|
|
63
|
+
|
|
64
|
+
raise LiveCable::Forbidden, "Non-writable reactive variable: #{dispatch.name}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return if component.class.allowed_actions.include?(dispatch.name)
|
|
76
68
|
|
|
77
|
-
|
|
78
|
-
ActionDispatch::ParamBuilder.from_pairs(
|
|
79
|
-
ActionDispatch::QueryParser.each_pair(params)
|
|
80
|
-
)
|
|
81
|
-
)
|
|
69
|
+
raise LiveCable::Forbidden, "Unauthorized action: #{dispatch.name}"
|
|
82
70
|
end
|
|
83
71
|
end
|
|
84
72
|
end
|
|
@@ -10,7 +10,8 @@ module LiveCable
|
|
|
10
10
|
|
|
11
11
|
return container[variable] if container.key?(variable)
|
|
12
12
|
|
|
13
|
-
container[variable] = process_initial_value(component,
|
|
13
|
+
container[variable] = process_initial_value(component, initial_value)
|
|
14
|
+
container[variable]
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def set(container_name, variable, value)
|
|
@@ -31,25 +32,21 @@ module LiveCable
|
|
|
31
32
|
shared_changeset = containers[SHARED_CONTAINER]&.changeset
|
|
32
33
|
container = containers[component.live_id]
|
|
33
34
|
|
|
34
|
-
container.changeset | component.shared_reactive_variables.intersection(shared_changeset)
|
|
35
|
+
changeset = container.changeset | component.shared_reactive_variables.intersection(shared_changeset)
|
|
36
|
+
return changeset if changeset.empty?
|
|
37
|
+
|
|
38
|
+
changeset | component.shared_variables
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
private
|
|
38
42
|
|
|
39
|
-
def process_initial_value(component,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
initial_value.call(*args)
|
|
48
|
-
else
|
|
49
|
-
raise LiveCable::Error, "Initial value for \":#{variable}\" must be a proc or nil"
|
|
50
|
-
end
|
|
51
|
-
rescue StandardError => e
|
|
52
|
-
handle_error(component, e)
|
|
43
|
+
def process_initial_value(component, initial_value)
|
|
44
|
+
return if initial_value.nil?
|
|
45
|
+
|
|
46
|
+
args = []
|
|
47
|
+
args << component if initial_value.arity.positive?
|
|
48
|
+
|
|
49
|
+
initial_value.call(*args)
|
|
53
50
|
end
|
|
54
51
|
end
|
|
55
52
|
end
|