live_cable 0.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 22855e882f2fd803fffc8bf2614fd3aa4e1b47a3d0dd4a686a7d3adb90e79bba
4
+ data.tar.gz: 1c9522b46962dcb5cf0aa6336d2243edb162167344dc48c37820ef3a3a16154d
5
+ SHA512:
6
+ metadata.gz: ee3630ec8ca212780062cbfc217d734c65d5b0ad558e2fb058858c1452e4f181ec5eba7b508e100921025683ffc44dc1b62c7a7163a0747c2e9f9bf6f27f8353
7
+ data.tar.gz: 8bc8de345fa73f1a67eefa9b59b09dfff59052758221c395a005ec4204708b4112724a355ec93f752c0758abd304d87fab3ca9321305262418213fecf79a4ce4
@@ -0,0 +1,123 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import SubscriptionManager from "live_cable_subscriptions"
3
+
4
+ export default class extends Controller {
5
+ static values = {
6
+ defaults: Object,
7
+ status: String,
8
+ component: String,
9
+ liveId: String,
10
+ }
11
+
12
+ #subscription
13
+ #formDebounce
14
+ #reactiveDebounce
15
+ #reactiveDebouncedMessage
16
+
17
+ #callActionCallback = (event) => {
18
+ event.stopPropagation()
19
+
20
+ const { action, params } = event.detail
21
+
22
+ this.sendCall(action, params)
23
+ }
24
+
25
+ connect() {
26
+ this.element.addEventListener("call", this.#callActionCallback)
27
+
28
+ this.#subscription = SubscriptionManager.subscribe(
29
+ this.liveIdValue,
30
+ this.componentValue,
31
+ this.defaultsValue,
32
+ this
33
+ )
34
+ }
35
+
36
+ disconnect() {
37
+ this.element.removeEventListener("call", this.#callActionCallback)
38
+ }
39
+
40
+ call({ params }) {
41
+ this.sendCall(params.action, params)
42
+ }
43
+
44
+ sendCall(action, params = {}) {
45
+ this.#subscription.send(
46
+ this.#unshiftDebounced(this.#callMessage(params, action))
47
+ )
48
+ }
49
+
50
+ #callMessage(params, action) {
51
+ return {
52
+ _action: action,
53
+ params: new URLSearchParams(params).toString(),
54
+ }
55
+ }
56
+
57
+ reactive({ target }) {
58
+ this.sendReactive(target)
59
+ }
60
+
61
+ sendReactive(target) {
62
+ this.#subscription.send(
63
+ this.#unshiftDebounced(this.#reactiveMessage(target))
64
+ )
65
+ }
66
+
67
+ #reactiveMessage(target) {
68
+ return {
69
+ _action: '_reactive',
70
+ name: target.name,
71
+ value: target.value,
72
+ }
73
+ }
74
+
75
+ #unshiftDebounced(message) {
76
+ const messages = [message]
77
+
78
+ if (this.#reactiveDebouncedMessage) {
79
+ messages.unshift(this.#reactiveDebouncedMessage)
80
+ this.#reactiveDebouncedMessage = null
81
+ }
82
+
83
+ return { messages, _csrf_token: this.#csrfToken }
84
+ }
85
+
86
+ reactiveDebounce(event) {
87
+ clearTimeout(this.#reactiveDebounce)
88
+ this.#reactiveDebouncedMessage = this.#reactiveMessage(event.target)
89
+
90
+ this.#reactiveDebounce = setTimeout(() => {
91
+ this.#reactiveDebouncedMessage = null
92
+ this.reactive(event)
93
+ }, event.params.debounce || 200)
94
+ }
95
+
96
+ form({ currentTarget, params: { action } }) {
97
+ this.sendForm(action, currentTarget)
98
+ }
99
+
100
+ sendForm(action, formEl) {
101
+ // Clear reactive debounce so it doesn't fire after form
102
+ clearTimeout(this.#reactiveDebounce)
103
+ clearTimeout(this.#formDebounce)
104
+
105
+ const formData = new FormData(formEl)
106
+ const params = new URLSearchParams(formData).toString()
107
+
108
+ this.#subscription.send(
109
+ this.#unshiftDebounced(this.#callMessage(params, action))
110
+ )
111
+ }
112
+
113
+ formDebounce(event) {
114
+ clearTimeout(this.#formDebounce)
115
+ this.#formDebounce = setTimeout(() => {
116
+ this.form(event)
117
+ }, event.params.debounce || 200)
118
+ }
119
+
120
+ get #csrfToken() {
121
+ return document.querySelector("meta[name='csrf-token']")?.getAttribute("content")
122
+ }
123
+ }
@@ -0,0 +1,20 @@
1
+ import LiveController from "live_cable_controller"
2
+
3
+ export default function LiveCableBlessing(constructor) {
4
+ Object.assign(constructor.prototype, {
5
+ liveCableAction(action, params = {}) {
6
+ this.context.logDebugActivity("liveCableAction", {
7
+ action,
8
+ params,
9
+ })
10
+
11
+ this.dispatch('call', {
12
+ detail: {
13
+ action,
14
+ params,
15
+ },
16
+ prefix: null,
17
+ })
18
+ }
19
+ })
20
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * LiveCable Subscription Manager
3
+ *
4
+ * This module implements subscription persistence for LiveCable components.
5
+ * Instead of creating new ActionCable subscriptions every time a Stimulus controller
6
+ * connects/disconnects, we maintain a single subscription per component instance
7
+ * (identified by liveId) and simply update the controller reference.
8
+ *
9
+ * Architecture:
10
+ * - SubscriptionManager: Singleton that manages all active subscriptions
11
+ * - Subscription: Wraps an ActionCable subscription and handles morphdom updates
12
+ * - Controller reconnection: When a controller disconnects/reconnects (e.g., due to
13
+ * Turbo navigation), the subscription persists and just updates its controller reference
14
+ *
15
+ * Benefits:
16
+ * - Reduces WebSocket churn
17
+ * - Maintains server-side state across page transitions
18
+ * - Eliminates race conditions from rapid connect/disconnect cycles
19
+ */
20
+
21
+ import { createConsumer } from "@rails/actioncable"
22
+ import morphdom from "morphdom"
23
+
24
+ const consumer = createConsumer()
25
+
26
+ /**
27
+ * Manages all LiveCable subscriptions across the application.
28
+ * Ensures that each component (identified by liveId) has at most one
29
+ * active ActionCable subscription at any time.
30
+ */
31
+ class SubscriptionManager {
32
+ /** @type {Object.<string, Subscription>} */
33
+ #subscriptions = {}
34
+
35
+ /**
36
+ * Subscribe to or reconnect to a LiveCable component.
37
+ * If a subscription already exists for this liveId, updates the controller
38
+ * reference instead of creating a new subscription.
39
+ *
40
+ * @param {string} liveId - Unique identifier for the component instance
41
+ * @param {string} component - Component class name (e.g., "counter")
42
+ * @param {Object} defaults - Default values for reactive variables
43
+ * @param {Object} controller - Stimulus controller instance
44
+ * @returns {Subscription} The subscription instance
45
+ */
46
+ subscribe(liveId, component, defaults, controller) {
47
+ if (!this.#subscriptions[liveId]) {
48
+ this.#subscriptions[liveId] = new Subscription(liveId, component, defaults, controller)
49
+ }
50
+
51
+ this.#subscriptions[liveId].controller = controller
52
+
53
+ return this.#subscriptions[liveId]
54
+ }
55
+
56
+ /**
57
+ * Remove a subscription from the manager.
58
+ * Called when the server sends a 'destroy' status, indicating the
59
+ * component instance should be permanently removed.
60
+ *
61
+ * @param {string} liveId - Unique identifier for the component instance
62
+ */
63
+ unsubscribe(liveId) {
64
+ delete this.#subscriptions[liveId]
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Represents a single ActionCable subscription to a LiveCable component.
70
+ * Handles receiving updates from the server and applying them to the DOM
71
+ * via morphdom.
72
+ */
73
+ class Subscription {
74
+ /** @type {string} */
75
+ #liveId
76
+ /** @type {string} */
77
+ #component
78
+ /** @type {Object} */
79
+ #defaults
80
+ /** @type {Object|null} */
81
+ #controller
82
+ /** @type {Object} */
83
+ #subscription
84
+
85
+ /**
86
+ * Creates a new subscription to a LiveCable component.
87
+ *
88
+ * @param {string} liveId - Unique identifier for the component instance
89
+ * @param {string} component - Component class name (e.g., "counter")
90
+ * @param {Object} defaults - Default values for reactive variables
91
+ * @param {Object} controller - Stimulus controller instance
92
+ */
93
+ constructor(liveId, component, defaults, controller) {
94
+ this.#liveId = liveId
95
+ this.#component = component
96
+ this.#defaults = defaults
97
+ this.#controller = controller
98
+ this.#subscribe()
99
+ }
100
+
101
+ /**
102
+ * Update the controller reference.
103
+ * Called when a Stimulus controller reconnects to an existing subscription.
104
+ *
105
+ * @param {Object} controller - Stimulus controller instance
106
+ */
107
+ set controller(controller) {
108
+ this.#controller = controller
109
+ }
110
+
111
+ /**
112
+ * Send a message to the server through the ActionCable subscription.
113
+ *
114
+ * @param {Object} message - Message to send (e.g., action calls, reactive updates)
115
+ */
116
+ send(message) {
117
+ this.#subscription.send(message)
118
+ }
119
+
120
+ /**
121
+ * Create the underlying ActionCable subscription.
122
+ * @private
123
+ */
124
+ #subscribe() {
125
+ this.#subscription = consumer.subscriptions.create({
126
+ channel: "LiveChannel",
127
+ component: this.#component,
128
+ defaults: this.#defaults,
129
+ live_id: this.#liveId,
130
+ }, {
131
+ received: this.#received,
132
+ })
133
+ }
134
+
135
+ /**
136
+ * Handle incoming messages from the server.
137
+ * Processes status updates and DOM refreshes.
138
+ *
139
+ * @param {Object} data - Data received from the server
140
+ * @param {string} [data._status] - Status update (e.g., 'subscribed', 'destroy')
141
+ * @param {string} [data._refresh] - HTML to morph into the DOM
142
+ * @private
143
+ */
144
+ #received = (data) => {
145
+ // Handle destroy status - permanently remove this subscription
146
+ if (data['_status'] === 'destroy') {
147
+ this.#subscription.unsubscribe()
148
+ subscriptionManager.unsubscribe(this.#liveId)
149
+ }
150
+
151
+ // If no controller is attached, we can't update the DOM
152
+ if (!this.#controller) {
153
+ return
154
+ }
155
+
156
+ // Update connection status
157
+ if (data['_status']) {
158
+ this.#controller.statusValue = data['_status']
159
+ }
160
+ // Apply DOM updates via morphdom
161
+ else if (data['_refresh']) {
162
+ morphdom(this.#controller.element, data['_refresh'], {
163
+ // Preserve elements marked with live-ignore attribute
164
+ onBeforeElUpdated(fromEl, toEl) {
165
+ if (!fromEl.hasAttribute) {
166
+ return true
167
+ }
168
+
169
+ return !fromEl.hasAttribute('live-ignore')
170
+ },
171
+ // Use stable keys for better morphing performance and state preservation
172
+ getNodeKey(node) {
173
+ if (!node) {
174
+ return
175
+ }
176
+
177
+ if (node.getAttribute) {
178
+ return node.getAttribute('live-key') ||
179
+ node.getAttribute('data-live-live-io-value') ||
180
+ node.getAttribute('id') ||
181
+ node.id
182
+ }
183
+ }
184
+ })
185
+ }
186
+ }
187
+ }
188
+
189
+ const subscriptionManager = new SubscriptionManager()
190
+
191
+ export default subscriptionManager
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LiveChannel < ActionCable::Channel::Base
4
+ def subscribed
5
+ instance = params[:live_id].present? && live_connection.get_component(params[:live_id])
6
+ rendered = instance.present?
7
+
8
+ unless instance
9
+ instance = LiveCable.instance_from_string(params[:component], params[:live_id])
10
+ live_connection.add_component(instance)
11
+ instance.defaults = params[:defaults]
12
+ end
13
+
14
+ stream_from(instance.channel_name)
15
+
16
+ instance.channel = self
17
+ instance.connected # @todo - Should this be called multiple times?
18
+ instance.broadcast_subscribe
19
+ instance.render_broadcast unless rendered
20
+
21
+ live_connection.set_channel(instance, self)
22
+
23
+ @component = instance
24
+ end
25
+
26
+ def receive(data)
27
+ live_connection.receive(component, data)
28
+ end
29
+
30
+ def unsubscribed
31
+ return unless component
32
+
33
+ stop_stream_from(component.channel_name)
34
+ component.disconnected
35
+ live_connection.remove_component(component)
36
+ live_connection.remove_channel(component)
37
+ component.live_connection = nil
38
+ @component = nil
39
+ end
40
+
41
+ private
42
+
43
+ # @return [LiveCable::Component, nil]
44
+ attr_reader :component
45
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LiveCableHelper
4
+ def live_component(component, **, &block)
5
+ tag.div(**live_attributes(component, component.defaults, **)) do
6
+ capture { block.call }
7
+ end
8
+ end
9
+
10
+ # @param [LiveCable::Component] component
11
+ def with_render_context(component, &)
12
+ context = LiveCable::RenderContext.new(component)
13
+ context_stack.push(context)
14
+
15
+ begin
16
+ value = yield
17
+ ensure
18
+ context_stack.pop
19
+ end
20
+
21
+ [value, context]
22
+ end
23
+
24
+ def live(component, **options)
25
+ renderable = component
26
+ id = options.delete(:id)
27
+
28
+ if renderable.is_a?(String)
29
+ live_id = "#{renderable}/#{id}"
30
+
31
+ renderable = if (existing = render_context&.get_component(live_id))
32
+ existing
33
+ else
34
+ LiveCable.instance_from_string(renderable, id)
35
+ end
36
+ end
37
+
38
+ # @todo Move to live_component so direct renders work too, we would need to assign the options
39
+ # again when a connection is assigned so it no loner uses the local container
40
+ render_context&.add_component(renderable)
41
+
42
+ renderable.defaults = options
43
+
44
+ render(renderable)
45
+ end
46
+
47
+ def live_attributes(component, defaults = {}, **options)
48
+ options.merge(
49
+ {
50
+ data: {
51
+ controller: "live #{options.with_indifferent_access.dig(:data, :controller)}".rstrip,
52
+ live_defaults_value: defaults.to_json,
53
+ live_component_value: component.class.component_string,
54
+ live_live_id_value: component.live_id,
55
+ live_status_value: component.status,
56
+ },
57
+ }
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ def context_stack
64
+ @context_stack ||= []
65
+ end
66
+
67
+ def render_context
68
+ context_stack.last
69
+ end
70
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin 'live_cable_controller', to: 'controllers/live_controller.js'
4
+ pin 'live_cable_blessing', to: 'live_cable_blessing.js'
5
+ pin 'live_cable_subscriptions', to: 'subscriptions.js'