live_cable 0.1.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0376ac919c3ed29e3c63181364e512f51aa39aeeb84f1b8a0584b42e1c89985
4
- data.tar.gz: 8bb42eb86866bddf18fb83e0a16770c4ddb48c3ec22524b680e272d155e22431
3
+ metadata.gz: a08ab617cc974271c9f4b2784bb52ca53d1430942fd72e761fe52cd650a86141
4
+ data.tar.gz: abd1b26ca97b149de015b9fb7b9b457ff6b6f21e224a61318788876115fcd9d9
5
5
  SHA512:
6
- metadata.gz: 4f95c867c6f65a85ceca7fad4c23cc572bc1cfb6c90e008ebb24403dbc7624cd9977ae711bc133484612b0e3ac16440f3d18bf365c656c8d537f009387dfd0fe
7
- data.tar.gz: 01d716fd63f39335bbaaed9aa8b0643f5b9a1493b9f1ca449635b4aa7f3cecc6d67992f31503412cbba6fd53860778918627430365af614b8598beb0666306b4
6
+ metadata.gz: e3b932fcbdfcf3fb5d6b939320798eb151c82d0e6bc85c61c2452ff729233855b477752e10465c8098f4601651d04f9e67449293b9635cde3eefa47bde08733c
7
+ data.tar.gz: 6476ba238c1960655922da93588b175ed0468294364dd1252b05ad044eb26cb0d94360254581cb791ddc6def81155940c82d310835398b7c791b8cb3daf6884c
data/CHANGELOG.md ADDED
@@ -0,0 +1,82 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The Ruby gem (`live_cable`) and the npm package (`@isometriks/live_cable`) are
6
+ released together and share a single version number. Entries below note which
7
+ side of the pair a change affects when it isn't both.
8
+
9
+ ## 0.2.0
10
+
11
+ The gem and npm package versions are realigned in this release. The npm package
12
+ jumps from 0.1.1 straight to 0.2.0, skipping 0.1.2, which was published for the
13
+ gem only.
14
+
15
+ ### Added
16
+
17
+ - **Server-dispatched DOM events.** Components can queue browser events with
18
+ `dispatch_event`, delivered with the next broadcast and fired after the DOM has
19
+ been morphed, so handlers observe the updated markup. Events are bubbling
20
+ `CustomEvent`s dispatched from the component root (or from `window` with
21
+ `window: true`), so they can be wired up with plain Stimulus `data-action`
22
+ syntax. (`LiveCable::Component::Events`)
23
+ - **Loading states.** While a message is in flight, a `live-loading` attribute is
24
+ added to the component's root element and to the element that triggered the
25
+ message, so pending feedback can be styled with plain CSS. `live-disable-with`
26
+ on a button or submit button swaps its label and disables it for the duration
27
+ of the round trip; form values are serialized before anything is disabled.
28
+ Reactive inputs (`live-reactive`) receive `live-loading` but are never
29
+ disabled, so typing is not interrupted.
30
+ - **Component test harness.** `LiveCable::Testing` can be included in specs to
31
+ mount and drive components without a browser, via `live_mount`.
32
+ - `./loading` subpath export for the new loading module (npm).
33
+ - Gemspec `homepage_uri`, `documentation_uri`, and `bug_tracker_uri` metadata.
34
+
35
+ ### Changed
36
+
37
+ - Minimum Rails version raised from 7.0 to 7.1 (`actioncable`, `actionview`,
38
+ `activemodel`, `activesupport`) (gem).
39
+ - Herb upgraded from `~> 0.8.10` to `~> 0.10.2`, and `prism >= 1.0` added as an
40
+ explicit dependency (gem).
41
+ - Gem homepage now points at https://livecable.io rather than the RubyGems page.
42
+ - The `Live` namespace for user components moved out of `lib/live_cable.rb` into
43
+ its own `lib/live.rb`, required explicitly and ignored by the gem's Zeitwerk
44
+ loader (gem).
45
+ - Dev dependencies updated: Vitest 2.x to 4.x, happy-dom 15.x to 20.x (npm).
46
+
47
+ ### Fixed
48
+
49
+ - Template compiler: block sentinel tokens now carry a newline value so Herb's
50
+ whitespace helpers (`at_line_start?`, `preceding_token_ends_with_newline?`)
51
+ treat them as a line boundary instead of raising on a `nil` value.
52
+ - Template compiler: the closing `end` of an output block is emitted as plain
53
+ code instead of going through Herb's paren-balancing block-end helper, since
54
+ escaping is delegated to Rails' output buffer.
55
+
56
+ ## 0.1.2 - 2026-03-25
57
+
58
+ Gem only; no corresponding npm release.
59
+
60
+ ### Added
61
+
62
+ - JavaScript assets packaged so they can be consumed from package managers as
63
+ well as through the asset pipeline.
64
+
65
+ ## 0.1.1 - 2026-03-25
66
+
67
+ ### Added
68
+
69
+ - Component generator.
70
+
71
+ ### Changed
72
+
73
+ - Herb pinned to `0.8.*`, and other library versions pinned.
74
+
75
+ ### Fixed
76
+
77
+ - `render` inside a live template.
78
+ - Generator no longer creates `app/live` when the directory does not exist.
79
+
80
+ ## 0.1.0
81
+
82
+ Initial public release.
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  LiveCable is a Phoenix LiveView-style live component system for Ruby on Rails that tracks state server-side and allows
4
4
  you to call actions from the frontend using Stimulus with a React style state management API.
5
5
 
6
- **Full documentation: [livecable.io](https://livecable.io)**
6
+ **Full documentation: [livecable.io](https://livecable.io)** | **Sandbox app: [live_cable_app](https://github.com/isometriks/live_cable_app)**
7
7
 
8
8
  ## Features
9
9
 
@@ -1000,6 +1000,53 @@ The `live-key` attribute acts as a hint for the diffing algorithm to identify el
1000
1000
  <% end %>
1001
1001
  ```
1002
1002
 
1003
+ ## Loading States
1004
+
1005
+ Every interaction is a round trip to the server. While a message is in flight, LiveCable adds a `live-loading` attribute to the component's root element and to the element that triggered the message, so you can show pending feedback with plain CSS:
1006
+
1007
+ ```css
1008
+ button[live-loading] { opacity: 0.5; cursor: wait; }
1009
+
1010
+ .spinner { display: none; }
1011
+ [live-loading] .spinner { display: inline-block; }
1012
+ ```
1013
+
1014
+ To prevent double-clicks, mark buttons with `live-disable-with`. The element is disabled while the message is in flight and restored when the server responds. Give the attribute a value to also swap the label:
1015
+
1016
+ ```erb
1017
+ <button live-action="checkout" live-disable-with="Processing...">Checkout</button>
1018
+
1019
+ <form live-form="save">
1020
+ <input type="text" name="title">
1021
+ <button type="submit" live-disable-with="Saving...">Save</button>
1022
+ </form>
1023
+ ```
1024
+
1025
+ For forms, put `live-disable-with` on the submit button(s); form values are serialized before anything is disabled. Reactive inputs (`live-reactive`) get the `live-loading` attribute but are never disabled, so typing is not interrupted.
1026
+
1027
+ The loading state is cleared when the server responds — with a re-render, an error, or a lightweight acknowledgement when the action didn't change any state — so it never gets stuck.
1028
+
1029
+ ## Server Events
1030
+
1031
+ Components can trigger DOM events on the client with `dispatch_event` — for scroll-to-bottom, closing modals, toasts, or anything else the client should do after the server finishes:
1032
+
1033
+ ```ruby
1034
+ def send_message(params)
1035
+ messages << { text: params[:text] }
1036
+ dispatch_event('chat:message-sent')
1037
+ end
1038
+ ```
1039
+
1040
+ Events fire as bubbling `CustomEvent`s from the component's root element **after the DOM has been morphed**, so handlers see the updated markup. Plain Stimulus `data-action` syntax handles them — no LiveCable-specific JavaScript:
1041
+
1042
+ ```erb
1043
+ <div data-controller="chat" data-action="chat:message-sent->chat#scrollToBottom">
1044
+ ...
1045
+ </div>
1046
+ ```
1047
+
1048
+ Pass a payload (`dispatch_event('toast:show', message: 'Saved')`, available as `event.detail`) or target global listeners with `dispatch_event('analytics:tracked', window: true)`. Events work from actions, lifecycle callbacks, and `stream_from` callbacks, and are delivered exactly once, in order, whether or not the action re-rendered.
1049
+
1003
1050
  ## Compound Components
1004
1051
 
1005
1052
  By default, components render the partial at `app/views/live/component_name.html.live.erb`. You can organize your templates differently by marking a component as `compound`.
@@ -1237,6 +1284,27 @@ When a broadcast is received:
1237
1284
  - **Live dashboards**: Update metrics and charts in real-time
1238
1285
  - **Presence tracking**: Show who's currently online or viewing a resource
1239
1286
 
1287
+ ## Testing Components
1288
+
1289
+ LiveCable includes a test harness for fast, browser-free component tests. Actions go through the real message pipeline (whitelisting, parameter parsing, change tracking, re-rendering), so tests exercise what production runs:
1290
+
1291
+ ```ruby
1292
+ RSpec.describe Live::Counter do
1293
+ include LiveCable::Testing
1294
+
1295
+ it 'increments by the step size' do
1296
+ counter = live_mount('counter', step: 2)
1297
+
1298
+ counter.perform(:increment)
1299
+
1300
+ expect(counter.count).to eq(2)
1301
+ expect(counter.rendered).to have_css('[data-testid="counter-value"]', text: '2')
1302
+ end
1303
+ end
1304
+ ```
1305
+
1306
+ The harness supports client reactive updates (`set_reactive`), broadcast assertions (`broadcasts(:_refresh)`), simulated stream broadcasts (`receive_stream`), shared state across components mounted on the same connection, connection identifiers like `current_user`, and lifecycle testing via `unmount`. See the [testing guide](https://livecable.io/guide/testing) for the full API.
1307
+
1240
1308
  ## Error Handling
1241
1309
 
1242
1310
  When an unhandled exception is raised inside a component action, LiveCable replaces the component in the DOM with an error message and cleans up the server-side component.
@@ -1,5 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import SubscriptionManager from "@isometriks/live_cable/subscriptions"
3
+ import LoadingState from "@isometriks/live_cable/loading"
3
4
 
4
5
  export default class extends Controller {
5
6
  static values = {
@@ -12,16 +13,18 @@ export default class extends Controller {
12
13
 
13
14
  #subscription
14
15
  #debounces = new Map()
16
+ #loading
15
17
 
16
18
  #callActionCallback = (event) => {
17
19
  event.stopPropagation()
18
20
 
19
21
  const { action, params } = event.detail
20
22
 
21
- this.sendCall(action, params)
23
+ this.sendCall(action, params, event.target)
22
24
  }
23
25
 
24
26
  connect() {
27
+ this.#loading = new LoadingState(this.element)
25
28
  this.element.addEventListener("call", this.#callActionCallback)
26
29
 
27
30
  this.#subscription = SubscriptionManager.subscribe(
@@ -33,8 +36,8 @@ export default class extends Controller {
33
36
 
34
37
  // Create callbacks for each action or form
35
38
  this.actionsValue.forEach((action) => {
36
- this[`action_$${action}`] = ({ params }) => {
37
- this.sendCall(action, this.#convertKeysToSnakeCase(params))
39
+ this[`action_$${action}`] = ({ params, currentTarget }) => {
40
+ this.sendCall(action, this.#convertKeysToSnakeCase(params), currentTarget)
38
41
  }
39
42
 
40
43
  this[`form_$${action}`] = (event) => {
@@ -47,12 +50,30 @@ export default class extends Controller {
47
50
  this.element.removeEventListener("call", this.#callActionCallback)
48
51
  }
49
52
 
50
- sendCall(action, params = {}) {
53
+ sendCall(action, params = {}, trigger = null) {
54
+ this.#loading.start(trigger)
51
55
  this.#subscription.send(
52
56
  this.#flushDebounced(this.#callMessage(params, action))
53
57
  )
54
58
  }
55
59
 
60
+ // Called by the subscription when the server answers a message
61
+ // (refresh, ack, or error). Restores any live-loading / live-disable-with
62
+ // state once all in-flight messages have been answered.
63
+ finishLoading() {
64
+ this.#loading?.finish()
65
+ }
66
+
67
+ // Whether any message is still awaiting a server response
68
+ get isLoading() {
69
+ return this.#loading?.active ?? false
70
+ }
71
+
72
+ // Called by the subscription when the component is being torn down.
73
+ resetLoading() {
74
+ this.#loading?.reset()
75
+ }
76
+
56
77
  #callMessage(params, action) {
57
78
  return {
58
79
  _action: action,
@@ -83,6 +104,8 @@ export default class extends Controller {
83
104
 
84
105
  sendReactive(target) {
85
106
  this.#clearDebounce(target)
107
+ // Never disable reactive inputs while in flight - it would drop focus
108
+ this.#loading.start(target, { disable: false })
86
109
  this.#subscription.send(
87
110
  this.#flushDebounced(this.#reactiveMessage(target))
88
111
  )
@@ -114,9 +137,13 @@ export default class extends Controller {
114
137
  sendForm(action, formEl) {
115
138
  this.#clearDebounce(formEl)
116
139
 
140
+ // Serialize before starting the loading state - disabled controls
141
+ // (live-disable-with) are excluded from FormData
117
142
  const formData = new FormData(formEl)
118
143
  const params = new URLSearchParams(formData).toString()
119
144
 
145
+ this.#loading.start(formEl)
146
+
120
147
  this.#subscription.send(
121
148
  this.#flushDebounced(this.#callMessage(params, action))
122
149
  )
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Loading state tracking for LiveCable components.
3
+ *
4
+ * Tracks in-flight messages for a single component and reflects them in the
5
+ * DOM so users can style pending states with CSS:
6
+ *
7
+ * - The component root element gets a `live-loading` attribute while any
8
+ * message is awaiting a server response.
9
+ * - The element that triggered the message (button, form, input) also gets
10
+ * a `live-loading` attribute.
11
+ * - Elements with a `live-disable-with` attribute are disabled while the
12
+ * message is in flight. If the attribute has a value, the element's label
13
+ * (textContent, or value for inputs) is swapped for it.
14
+ *
15
+ * The state is cleared when the server responds with a refresh, an error,
16
+ * or an ack (sent when an action didn't change any reactive variables).
17
+ * Multiple in-flight messages are counted; the DOM is only restored once
18
+ * all of them have been answered.
19
+ */
20
+ export default class LoadingState {
21
+ /** @type {HTMLElement} */
22
+ #root
23
+ /** @type {number} */
24
+ #inFlight = 0
25
+ /** @type {Set<Element>} - Elements marked with the live-loading attribute */
26
+ #markedElements = new Set()
27
+ /** @type {Map<Element, Object>} - Original state of disabled elements */
28
+ #disabledElements = new Map()
29
+
30
+ /**
31
+ * @param {HTMLElement} root - The component's root element
32
+ */
33
+ constructor(root) {
34
+ this.#root = root
35
+ }
36
+
37
+ /**
38
+ * Whether any message is currently awaiting a server response.
39
+ * @returns {boolean}
40
+ */
41
+ get active() {
42
+ return this.#inFlight > 0
43
+ }
44
+
45
+ /**
46
+ * Mark the component as loading.
47
+ * Called right before a message is sent to the server.
48
+ *
49
+ * @param {Element|null} trigger - The element that triggered the message
50
+ * @param {Object} options
51
+ * @param {boolean} options.disable - Whether to process live-disable-with
52
+ * elements. Disabled for reactive inputs so typing doesn't lose focus.
53
+ */
54
+ start(trigger = null, { disable = true } = {}) {
55
+ this.#inFlight++
56
+
57
+ this.#mark(this.#root)
58
+
59
+ if (trigger instanceof Element && trigger !== this.#root) {
60
+ this.#mark(trigger)
61
+
62
+ if (disable) {
63
+ this.#disableElements(trigger)
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Record a server response for this component.
70
+ * Restores the DOM once all in-flight messages have been answered.
71
+ *
72
+ * @returns {boolean} true if the loading state was fully cleared
73
+ */
74
+ finish() {
75
+ if (this.#inFlight === 0) {
76
+ return false
77
+ }
78
+
79
+ this.#inFlight--
80
+
81
+ if (this.#inFlight > 0) {
82
+ return false
83
+ }
84
+
85
+ this.#restore()
86
+ return true
87
+ }
88
+
89
+ /**
90
+ * Clear all loading state immediately, regardless of in-flight count.
91
+ * Used when the component is being torn down (e.g. on error).
92
+ */
93
+ reset() {
94
+ this.#inFlight = 0
95
+ this.#restore()
96
+ }
97
+
98
+ #mark(element) {
99
+ element.setAttribute('live-loading', '')
100
+ this.#markedElements.add(element)
101
+ }
102
+
103
+ /**
104
+ * Disable the trigger and/or its descendants marked with live-disable-with.
105
+ * For a button the attribute lives on the button itself; for a form it
106
+ * usually lives on the submit button(s) inside it.
107
+ */
108
+ #disableElements(trigger) {
109
+ const elements = []
110
+
111
+ if (trigger.hasAttribute('live-disable-with')) {
112
+ elements.push(trigger)
113
+ }
114
+
115
+ elements.push(...trigger.querySelectorAll('[live-disable-with]'))
116
+
117
+ elements.forEach(element => {
118
+ // Already disabled by an earlier in-flight message
119
+ if (this.#disabledElements.has(element)) {
120
+ return
121
+ }
122
+
123
+ const text = element.getAttribute('live-disable-with')
124
+ const isInput = element instanceof HTMLInputElement
125
+
126
+ this.#disabledElements.set(element, {
127
+ disabled: element.disabled,
128
+ content: text ? (isInput ? element.value : element.textContent) : null,
129
+ })
130
+
131
+ element.disabled = true
132
+
133
+ if (text) {
134
+ if (isInput) {
135
+ element.value = text
136
+ } else {
137
+ element.textContent = text
138
+ }
139
+ }
140
+ })
141
+ }
142
+
143
+ #restore() {
144
+ this.#markedElements.forEach(element => {
145
+ element.removeAttribute('live-loading')
146
+ })
147
+ this.#markedElements.clear()
148
+
149
+ this.#disabledElements.forEach(({ disabled, content }, element) => {
150
+ element.disabled = disabled
151
+
152
+ if (content !== null) {
153
+ if (element instanceof HTMLInputElement) {
154
+ element.value = content
155
+ } else {
156
+ element.textContent = content
157
+ }
158
+ }
159
+ })
160
+ this.#disabledElements.clear()
161
+ }
162
+ }
@@ -396,6 +396,10 @@ class Subscription {
396
396
  * @param {string} [data._status] - Status update (e.g., 'subscribed', 'destroy')
397
397
  * @param {string} [data._refresh] - HTML to morph into the DOM
398
398
  * @param {string} [data._error] - Raw error HTML to replace the component with
399
+ * @param {boolean} [data._ack] - Acknowledgement that a message was processed
400
+ * without producing a re-render; clears the loading state
401
+ * @param {Array} [data._events] - Events to dispatch as CustomEvents; when
402
+ * attached to a _refresh they fire after the DOM has been morphed
399
403
  * @private
400
404
  */
401
405
  #received = (data) => {
@@ -405,7 +409,32 @@ class Subscription {
405
409
  this.#handleRefresh(data['_refresh'])
406
410
  } else if (data['_error']) {
407
411
  this.#handleError(data['_error'])
412
+ } else if (data['_ack']) {
413
+ this.#controller?.finishLoading()
408
414
  }
415
+
416
+ // Dispatch after the branch above so events attached to a refresh fire
417
+ // once the morph has completed and handlers see the updated DOM
418
+ if (data['_events']) {
419
+ this.#dispatchEvents(data['_events'])
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Fire server-dispatched events as bubbling CustomEvents from the
425
+ * component's root element, or from window when the event asks for it.
426
+ *
427
+ * @param {Array<{name: string, detail: Object, window: boolean}>} events
428
+ * @private
429
+ */
430
+ #dispatchEvents(events) {
431
+ events.forEach(({ name, detail, window: onWindow }) => {
432
+ const target = onWindow ? window : this.#controller?.element
433
+
434
+ if (target) {
435
+ target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }))
436
+ }
437
+ })
409
438
  }
410
439
 
411
440
  /**
@@ -420,6 +449,7 @@ class Subscription {
420
449
  return
421
450
  }
422
451
 
452
+ this.#controller.resetLoading()
423
453
  this.#controller.element.outerHTML = html
424
454
  this.unsubscribe()
425
455
  }
@@ -458,10 +488,40 @@ class Subscription {
458
488
  return
459
489
  }
460
490
 
461
- morphdom(this.#controller.element, this.#buildRefreshDOM(refresh), {
491
+ // Restore live-loading / live-disable-with state before morphing so the
492
+ // morph applies the server-rendered truth on top of the original DOM.
493
+ // With multiple messages in flight this only restores once the last
494
+ // response arrives - until then the morph below preserves the pending
495
+ // elements so live-disable-with buttons can't be clicked early.
496
+ this.#controller.finishLoading()
497
+
498
+ const rootElement = this.#controller.element
499
+ const stillLoading = this.#controller.isLoading
500
+
501
+ const refreshDOM = this.#buildRefreshDOM(refresh)
502
+
503
+ if (stillLoading) {
504
+ refreshDOM.setAttribute('live-loading', '')
505
+ }
506
+
507
+ morphdom(rootElement, refreshDOM, {
462
508
  // Preserve elements marked with live-ignore attribute
463
509
  onBeforeElUpdated(fromEl, toEl) {
464
- return fromEl.hasAttribute && !fromEl.hasAttribute('live-ignore')
510
+ if (!fromEl.hasAttribute) {
511
+ return true
512
+ }
513
+
514
+ if (fromEl.hasAttribute('live-ignore')) {
515
+ return false
516
+ }
517
+
518
+ // Keep elements that are still awaiting a server response untouched
519
+ // (the root is handled above so the rest of the tree still morphs)
520
+ if (stillLoading && fromEl !== rootElement && fromEl.hasAttribute('live-loading')) {
521
+ return false
522
+ }
523
+
524
+ return true
465
525
  },
466
526
  // Use stable keys for better morphing performance and state preservation
467
527
  getNodeKey(node) {
data/config/importmap.rb CHANGED
@@ -6,6 +6,7 @@ pin 'morphdom', to: 'https://ga.jspm.io/npm:morphdom@2.7.8/dist/morphdom-esm.js'
6
6
  pin '@isometriks/live_cable/controller', to: 'controllers/live_controller.js'
7
7
  pin '@isometriks/live_cable/blessing', to: 'live_cable_blessing.js'
8
8
  pin '@isometriks/live_cable/subscriptions', to: 'subscriptions.js'
9
+ pin '@isometriks/live_cable/loading', to: 'loading.js'
9
10
  pin '@isometriks/live_cable/observer', to: 'observer.js'
10
11
  pin '@isometriks/live_cable/dom', to: 'dom.js'
11
12
  pin '@isometriks/live_cable', to: 'live_cable.js'
data/lib/live.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Live
4
+ # For components to live in
5
+ end
@@ -13,6 +13,12 @@ module LiveCable
13
13
  broadcast({ _status: 'subscribed', id: live_id })
14
14
  end
15
15
 
16
+ # Sent when a received message didn't change any reactive variables,
17
+ # so the client can clear its loading state without a re-render.
18
+ def broadcast_ack
19
+ broadcast({ _ack: true })
20
+ end
21
+
16
22
  def broadcast_destroy
17
23
  broadcast({ _status: 'destroy' })
18
24
  @subscribed = false
@@ -20,9 +26,14 @@ module LiveCable
20
26
 
21
27
  def broadcast_render
22
28
  run_callbacks :render do
23
- broadcast(
24
- _refresh: render.as_json
25
- )
29
+ data = { _refresh: render.as_json }
30
+
31
+ # Events ride along with the render so the client can fire them
32
+ # after the DOM has been morphed
33
+ events = flush_events
34
+ data[:_events] = events if events.any?
35
+
36
+ broadcast(data)
26
37
  end
27
38
  end
28
39
  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
@@ -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,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.2'
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: live_cable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Craig Blanchette
@@ -15,70 +15,84 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.0'
18
+ version: '7.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '7.0'
25
+ version: '7.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: actionview
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '7.0'
32
+ version: '7.1'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '7.0'
39
+ version: '7.1'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: activemodel
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '7.0'
46
+ version: '7.1'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '7.0'
53
+ version: '7.1'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: activesupport
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: '7.0'
60
+ version: '7.1'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: '7.0'
67
+ version: '7.1'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: herb
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: 0.8.10
74
+ version: 0.10.2
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: 0.8.10
81
+ version: 0.10.2
82
+ - !ruby/object:Gem::Dependency
83
+ name: prism
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '1.0'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: zeitwerk
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -101,12 +115,14 @@ executables: []
101
115
  extensions: []
102
116
  extra_rdoc_files: []
103
117
  files:
118
+ - CHANGELOG.md
104
119
  - LICENSE
105
120
  - README.md
106
121
  - app/assets/javascript/controllers/live_controller.js
107
122
  - app/assets/javascript/dom.js
108
123
  - app/assets/javascript/live_cable.js
109
124
  - app/assets/javascript/live_cable_blessing.js
125
+ - app/assets/javascript/loading.js
110
126
  - app/assets/javascript/observer.js
111
127
  - app/assets/javascript/subscriptions.js
112
128
  - app/channels/live_channel.rb
@@ -115,9 +131,11 @@ files:
115
131
  - lib/generators/live_cable/component/component_generator.rb
116
132
  - lib/generators/live_cable/component/templates/component.rb.tt
117
133
  - lib/generators/live_cable/component/templates/view.html.live.erb.tt
134
+ - lib/live.rb
118
135
  - lib/live_cable.rb
119
136
  - lib/live_cable/component.rb
120
137
  - lib/live_cable/component/broadcasting.rb
138
+ - lib/live_cable/component/events.rb
121
139
  - lib/live_cable/component/identification.rb
122
140
  - lib/live_cable/component/lifecycle.rb
123
141
  - lib/live_cable/component/method_dependency_tracking.rb
@@ -155,14 +173,23 @@ files:
155
173
  - lib/live_cable/rendering/partial_renderer.rb
156
174
  - lib/live_cable/rendering/render_result.rb
157
175
  - lib/live_cable/rendering/renderer.rb
176
+ - lib/live_cable/testing.rb
177
+ - lib/live_cable/testing/render_state.rb
178
+ - lib/live_cable/testing/test_cable_connection.rb
179
+ - lib/live_cable/testing/test_channel.rb
180
+ - lib/live_cable/testing/test_component.rb
158
181
  - lib/live_cable/version.rb
159
- homepage: https://rubygems.org/gems/live_cable
182
+ homepage: https://livecable.io
160
183
  licenses:
161
184
  - MIT
162
185
  metadata:
163
186
  allowed_push_host: https://rubygems.org
164
187
  rubygems_mfa_required: 'true'
165
188
  source_code_uri: https://github.com/isometriks/live_cable
189
+ homepage_uri: https://livecable.io
190
+ documentation_uri: https://livecable.io
191
+ bug_tracker_uri: https://github.com/isometriks/live_cable/issues
192
+ changelog_uri: https://github.com/isometriks/live_cable/blob/main/CHANGELOG.md
166
193
  rdoc_options: []
167
194
  require_paths:
168
195
  - lib