reactive_component 0.8.5 → 0.9.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 +4 -4
- data/CHANGELOG.md +43 -0
- data/app/javascript/reactive_component/controllers/reactive_renderer_controller.js +24 -12
- data/app/javascript/reactive_component/lib/reactive_renderer_utils.js +12 -1
- data/app/jobs/reactive_component/broadcast_job.rb +17 -0
- data/lib/reactive_component/broadcastable.rb +12 -2
- data/lib/reactive_component/engine.rb +1 -0
- data/lib/reactive_component/entity.rb +9 -4
- data/lib/reactive_component/version.rb +1 -1
- data/lib/reactive_component/wrapper.rb +15 -2
- data/lib/reactive_component.rb +14 -5
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b5392838b8ed189cab22600b11b26eaa10215f601dd18294a4070febd9d88fbc
|
|
4
|
+
data.tar.gz: ed5fc1b4fb9c8e41ca0e717481323655b04b31bd4c94eb7ae011d4e05452b881
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bc1903d993c6504bccd2235175c131287589437461991084dfd93f6124f806de5589a669581bc9e3a34feac4fd5ad5f251131cabe498e5ad4218ade2db225574
|
|
7
|
+
data.tar.gz: 84a9b85096b7838863837ed0fac03be89394ceef37a24484a1425a9643f707dc120204a8c8b9bf90f2f5645e5d18ce87f04e0d4603a6a6c218f31f16b85d1bc1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.1] - 2026-09-17
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- The renderer logs every render and update only when `ReactiveComponent.debug`
|
|
7
|
+
is on, which it reads from the `data-reactive-debug` attribute already on the
|
|
8
|
+
wrapper. It used to log everywhere, production included, unless a
|
|
9
|
+
`devToolbar:debug` key in `localStorage` was set to the string `"false"`.
|
|
10
|
+
- A client-state change on a notify component that has no data yet asks the
|
|
11
|
+
server to render it instead of doing nothing, and an update request carries
|
|
12
|
+
the state the component is in now rather than the one the page was built
|
|
13
|
+
with.
|
|
14
|
+
- A notify wrapper with client state no longer embeds its initial data payload.
|
|
15
|
+
It asks the server to render it, so the attribute was the page a second time,
|
|
16
|
+
carrying every branch of every extracted expression. A push wrapper keeps it:
|
|
17
|
+
it renders from that payload until the first broadcast arrives.
|
|
18
|
+
|
|
19
|
+
## [0.9.0] - 2026-09-17
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- `subscribes_to ..., strategy: :notify` declares notify mode for every
|
|
23
|
+
instance. A broadcast then sends the bare signal instead of building a
|
|
24
|
+
payload no notify client reads, and the wrapper needs no
|
|
25
|
+
`live_wrapper_options`. An instance cannot switch such a class back to push.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- A broadcast now runs in an Active Job (`ReactiveComponent::BroadcastJob`)
|
|
29
|
+
instead of inside the request that committed the record, so rendering a
|
|
30
|
+
payload no longer holds the response. `subscribes_to ..., later: false`
|
|
31
|
+
keeps it inline. A destroy always broadcasts inline. The request id rides
|
|
32
|
+
along, so `skip_own_broadcasts` keeps working.
|
|
33
|
+
- A morph leaves `[data-turbo-permanent]` elements alone, as Turbo's own morph
|
|
34
|
+
does. An open menu marks itself permanent while it is open, and an update
|
|
35
|
+
underneath it no longer closes it.
|
|
36
|
+
- A `key` entity's dom id joins its parts with `_` instead of `-`
|
|
37
|
+
(`due_count_1_2`) and its gid with `/`. A page rendered before the deploy
|
|
38
|
+
routes broadcasts by the old id until it reloads.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
- A `key` entity joined its id on `-`, so a part carrying a dash, a UUID for
|
|
42
|
+
instance, never split back: every lookup missed and a notify component
|
|
43
|
+
never re-rendered. The id is now the array of parts, the way Rails exposes
|
|
44
|
+
a composite primary key, and GlobalID and `dom_id` encode it themselves.
|
|
45
|
+
|
|
3
46
|
## [0.8.5] - 2026-09-16
|
|
4
47
|
|
|
5
48
|
### Added
|
|
@@ -3,11 +3,6 @@ import { createConsumer } from "@rails/actioncable"
|
|
|
3
3
|
import { compileTemplate, decompress, morphElement, buildActionBody, routeMessage, duplicateIds, strictData } from "reactive_component/lib/reactive_renderer_utils"
|
|
4
4
|
|
|
5
5
|
const consumer = createConsumer()
|
|
6
|
-
const log = (...args) => {
|
|
7
|
-
if (localStorage.getItem("devToolbar:debug") !== "false") {
|
|
8
|
-
console.log("[reactive-renderer]", ...args)
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
6
|
|
|
12
7
|
function findSubscription(streamValue) {
|
|
13
8
|
const identifier = JSON.stringify({ channel: "ReactiveComponent::Channel", signed_stream_name: streamValue })
|
|
@@ -113,6 +108,14 @@ export default class extends Controller {
|
|
|
113
108
|
this.element.removeAttribute("data-reactive-renderer-connected")
|
|
114
109
|
}
|
|
115
110
|
|
|
111
|
+
// ReactiveComponent.debug marks the wrapper, so the gem is quiet unless the
|
|
112
|
+
// app turns debugging on server-side.
|
|
113
|
+
log(...args) {
|
|
114
|
+
if (this.element.hasAttribute("data-reactive-debug")) {
|
|
115
|
+
console.log("[reactive-renderer]", ...args)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
116
119
|
resolveTemplate() {
|
|
117
120
|
if (this.hasTemplateValue) return this.templateValue
|
|
118
121
|
|
|
@@ -129,18 +132,18 @@ export default class extends Controller {
|
|
|
129
132
|
|
|
130
133
|
switch (route.type) {
|
|
131
134
|
case "render":
|
|
132
|
-
log("render", this.element.id, route.data)
|
|
135
|
+
this.log("render", this.element.id, route.data)
|
|
133
136
|
this.lastServerData = route.data
|
|
134
137
|
if (this.renderFn) this.render({ ...route.data, ...this.clientState })
|
|
135
138
|
break
|
|
136
139
|
|
|
137
140
|
case "request_update":
|
|
138
|
-
log("update", this.element.id, { action: message.action, strategy: "notify" })
|
|
141
|
+
this.log("update", this.element.id, { action: message.action, strategy: "notify" })
|
|
139
142
|
this.requestUpdate()
|
|
140
143
|
break
|
|
141
144
|
|
|
142
145
|
case "update":
|
|
143
|
-
log("update", this.element.id, route.data)
|
|
146
|
+
this.log("update", this.element.id, route.data)
|
|
144
147
|
this.lastServerData = route.data
|
|
145
148
|
if (this.renderFn) this.render({ ...route.data, ...this.clientState })
|
|
146
149
|
this.element.dispatchEvent(new CustomEvent("reactive-renderer:updated", {
|
|
@@ -168,7 +171,9 @@ export default class extends Controller {
|
|
|
168
171
|
component: this.componentValue,
|
|
169
172
|
sgid: this.sgidValue,
|
|
170
173
|
dom_id: this.element.id,
|
|
171
|
-
params
|
|
174
|
+
// The channel reads client state off params, so the server renders the
|
|
175
|
+
// state the component is in now, not the one the page was built with.
|
|
176
|
+
params: { ...this.paramsValue, ...this.clientState }
|
|
172
177
|
})
|
|
173
178
|
}, 50)
|
|
174
179
|
}
|
|
@@ -262,16 +267,23 @@ export default class extends Controller {
|
|
|
262
267
|
changed = true
|
|
263
268
|
}
|
|
264
269
|
}
|
|
265
|
-
if (changed
|
|
266
|
-
requestAnimationFrame(() => ctrl.render({ ...ctrl.lastServerData, ...ctrl.clientState }))
|
|
267
|
-
}
|
|
270
|
+
if (changed) ctrl.rerender()
|
|
268
271
|
})
|
|
269
272
|
}
|
|
270
273
|
}
|
|
271
274
|
|
|
272
275
|
Object.assign(this.clientState, updates)
|
|
276
|
+
this.rerender()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// A push component holds the data it needs. A notify one may not have any
|
|
280
|
+
// yet, its payload arrives on request, so a state change asks for it rather
|
|
281
|
+
// than leaving the click with nothing to show.
|
|
282
|
+
rerender() {
|
|
273
283
|
if (this.lastServerData && this.renderFn) {
|
|
274
284
|
requestAnimationFrame(() => this.render({ ...this.lastServerData, ...this.clientState }))
|
|
285
|
+
} else if (this.strategyValue === "notify") {
|
|
286
|
+
this.requestUpdate()
|
|
275
287
|
}
|
|
276
288
|
}
|
|
277
289
|
|
|
@@ -44,9 +44,20 @@ const HOOK_NAMES = [
|
|
|
44
44
|
'beforeNodeRemoved', 'afterNodeRemoved', 'beforeAttributeUpdated'
|
|
45
45
|
]
|
|
46
46
|
|
|
47
|
+
// Turbo's convention: an element marked permanent is left as it is. A menu
|
|
48
|
+
// that is open, or anything else the page is in the middle of, says so with
|
|
49
|
+
// the attribute and the morph steps over it. Like Turbo's, it needs an id, so
|
|
50
|
+
// the morph pairs it with its counterpart instead of adding a second copy.
|
|
51
|
+
const isPermanent = (node) => node.nodeType === Node.ELEMENT_NODE && node.id !== "" && node.hasAttribute("data-turbo-permanent")
|
|
52
|
+
|
|
53
|
+
const skipPermanent = {
|
|
54
|
+
beforeNodeMorphed: (currentNode) => !isPermanent(currentNode),
|
|
55
|
+
beforeNodeRemoved: (node) => !isPermanent(node)
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
const morphCallbacks = Object.fromEntries(HOOK_NAMES.map(name => [
|
|
48
59
|
name,
|
|
49
|
-
(...args) => morphHooks.reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
|
|
60
|
+
(...args) => [skipPermanent, ...morphHooks].reduce((ok, hook) => (hook[name]?.(...args) === false ? false : ok), true)
|
|
50
61
|
]))
|
|
51
62
|
|
|
52
63
|
export function morphElement(element, newHtml) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ReactiveComponent
|
|
4
|
+
# Carries a broadcast out of the request that committed the record. The
|
|
5
|
+
# request id travels with it so a client can still skip its own broadcast.
|
|
6
|
+
# A record deleted before the job runs has nothing left to render, so the
|
|
7
|
+
# job is dropped rather than retried.
|
|
8
|
+
class BroadcastJob < ActiveJob::Base
|
|
9
|
+
discard_on ActiveJob::DeserializationError
|
|
10
|
+
|
|
11
|
+
def perform(component_class_name, record, action, request_id = nil)
|
|
12
|
+
Turbo.with_request_id(request_id) do
|
|
13
|
+
ReactiveComponent.broadcast_for(component_class_name.constantize, record, action: action.to_sym)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -28,7 +28,7 @@ module ReactiveComponent
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def broadcast_reactive(action)
|
|
31
|
-
reactive_component_classes.each { |klass|
|
|
31
|
+
reactive_component_classes.each { |klass| _broadcast_reactive_for(klass, action) }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def broadcast_reactive_update = broadcast_reactive(:update)
|
|
@@ -44,10 +44,20 @@ module ReactiveComponent
|
|
|
44
44
|
fields = klass._subscribed_fields
|
|
45
45
|
next if fields && !saved_changes.keys.intersect?(fields)
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
_broadcast_reactive_for(klass, :update)
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def _broadcast_reactive_destroy = broadcast_reactive(:destroy)
|
|
52
|
+
|
|
53
|
+
# A destroyed record cannot ride a job: it is gone by the time the job
|
|
54
|
+
# looks it up. Its signal is cheap, so it goes out inline. So does a
|
|
55
|
+
# component the job could not find by name, an anonymous class in a test.
|
|
56
|
+
def _broadcast_reactive_for(klass, action)
|
|
57
|
+
later = action != :destroy && klass.name && klass.try(:_broadcast_later)
|
|
58
|
+
return ReactiveComponent.broadcast_for(klass, self, action: action) unless later
|
|
59
|
+
|
|
60
|
+
ReactiveComponent::BroadcastJob.perform_later(klass.name, self, action.to_s, Turbo.current_request_id)
|
|
61
|
+
end
|
|
52
62
|
end
|
|
53
63
|
end
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# Entities and signed stream ids are GlobalIDs. ActiveJob pulls this railtie in
|
|
6
6
|
# for its own arguments; an app without ActiveJob would otherwise have no
|
|
7
7
|
# `GlobalID.app` and could not create one.
|
|
8
|
+
require 'active_job/railtie'
|
|
8
9
|
require 'global_id/railtie'
|
|
9
10
|
|
|
10
11
|
module ReactiveComponent
|
|
@@ -36,6 +36,7 @@ module ReactiveComponent
|
|
|
36
36
|
# end
|
|
37
37
|
module Entity
|
|
38
38
|
extend ActiveSupport::Concern
|
|
39
|
+
|
|
39
40
|
include ActiveModel::Model
|
|
40
41
|
include Broadcastable
|
|
41
42
|
include GlobalID::Identification
|
|
@@ -49,7 +50,7 @@ module ReactiveComponent
|
|
|
49
50
|
# Turbo's `stream_name_from` prefers `to_gid_param`, so an entity names its
|
|
50
51
|
# own stream. `to_param` stays as the fallback for when `GlobalID.app`
|
|
51
52
|
# isn't set: a bare id would collide with every other entity sharing it.
|
|
52
|
-
def to_param = "#{self.class.model_name.param_key}/#{
|
|
53
|
+
def to_param = "#{self.class.model_name.param_key}/#{to_key.join('/')}"
|
|
53
54
|
|
|
54
55
|
class_methods do
|
|
55
56
|
# The record the entity is keyed on. Defines `initialize(<name>:)`, the
|
|
@@ -67,7 +68,10 @@ module ReactiveComponent
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
# An entity keyed on plain values instead of a record. Defines an `id`
|
|
70
|
-
# that
|
|
71
|
+
# that is the array of values, the way Rails exposes a composite primary
|
|
72
|
+
# key: `dom_id` joins them with `_` and GlobalID escapes each on its own,
|
|
73
|
+
# so a part may carry a dash or a colon. `primary_key` names the parts
|
|
74
|
+
# for GlobalID's arity check. Also defines the
|
|
71
75
|
# `find` / `find_by(id:)` the channel and actions controller need, and,
|
|
72
76
|
# as defaults you can replace, the readers, `initialize(company_id:,
|
|
73
77
|
# user_id:)` and `from_key`. A key with the wrong arity resolves to nil
|
|
@@ -95,11 +99,12 @@ module ReactiveComponent
|
|
|
95
99
|
attr_reader(*names)
|
|
96
100
|
|
|
97
101
|
define_method(:initialize) { |**kwargs| names.each { |n| instance_variable_set(:"@#{n}", kwargs.fetch(n)) } }
|
|
98
|
-
define_method(:id) { names.map { |n| public_send(n) }
|
|
102
|
+
define_method(:id) { names.map { |n| public_send(n) } }
|
|
103
|
+
define_singleton_method(:primary_key) { names }
|
|
99
104
|
|
|
100
105
|
define_singleton_method(:from_key) { |**values| new(**values) }
|
|
101
106
|
define_singleton_method(:find_by) do |id:|
|
|
102
|
-
values = id
|
|
107
|
+
values = Array(id)
|
|
103
108
|
from_key(**names.zip(values).to_h) if values.size == names.size
|
|
104
109
|
end
|
|
105
110
|
define_singleton_method(:find) { |id| find_by(id: id) }
|
|
@@ -12,6 +12,12 @@ module ReactiveComponent
|
|
|
12
12
|
params: nil, template_id: nil, skip_own_broadcasts: ReactiveComponent.skip_own_broadcasts)
|
|
13
13
|
dom_id_val = component_class.dom_id_for(record)
|
|
14
14
|
|
|
15
|
+
# A notify class broadcasts no data, so an instance cannot go back to push.
|
|
16
|
+
if component_class.notify? && strategy.to_s != 'notify'
|
|
17
|
+
raise ArgumentError, "#{component_class.name} declares strategy: :notify; " \
|
|
18
|
+
"the wrapper cannot switch it to #{strategy.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
15
21
|
# A notify component asks the server to re-render it, so it carries a
|
|
16
22
|
# signed id of the record it is allowed to ask about. The raw id stays
|
|
17
23
|
# out of it: the client already has one in its data.
|
|
@@ -39,8 +45,15 @@ module ReactiveComponent
|
|
|
39
45
|
|
|
40
46
|
if client_state
|
|
41
47
|
attrs << %(data-reactive-renderer-state-value="#{ERB::Util.html_escape(client_state.to_json)}")
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
|
|
49
|
+
# A push wrapper renders from this payload until the first broadcast
|
|
50
|
+
# arrives. A notify one asks the server for its data instead, so the
|
|
51
|
+
# attribute would be the page a second time, both branches of every
|
|
52
|
+
# extracted expression included.
|
|
53
|
+
unless strategy.to_s == 'notify'
|
|
54
|
+
initial_data = component_class.build_data(record, **client_state.symbolize_keys)
|
|
55
|
+
attrs << %(data-reactive-renderer-data-value="#{ERB::Util.html_escape(initial_data.to_json)}")
|
|
56
|
+
end
|
|
44
57
|
end
|
|
45
58
|
|
|
46
59
|
attrs << %(data-reactive-renderer-strategy-value="#{strategy}") if strategy
|
data/lib/reactive_component.rb
CHANGED
|
@@ -38,6 +38,8 @@ module ReactiveComponent
|
|
|
38
38
|
class_attribute :_client_state_fields, instance_writer: false, default: {}
|
|
39
39
|
class_attribute :_subscribed_events, instance_writer: false, default: %i[create update destroy]
|
|
40
40
|
class_attribute :_subscribed_fields, instance_writer: false, default: nil
|
|
41
|
+
class_attribute :_strategy, instance_writer: false, default: nil
|
|
42
|
+
class_attribute :_broadcast_later, instance_writer: false, default: true
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def render_in(view_context, &)
|
|
@@ -60,6 +62,7 @@ module ReactiveComponent
|
|
|
60
62
|
end
|
|
61
63
|
|
|
62
64
|
extra_opts = respond_to?(:live_wrapper_options, true) ? live_wrapper_options : {}
|
|
65
|
+
extra_opts = { strategy: self.class._strategy }.merge(extra_opts) if self.class._strategy
|
|
63
66
|
|
|
64
67
|
wrapped = ReactiveComponent::Wrapper.wrap(self.class, record, inner_html, stream: stream, client_state: client_state,
|
|
65
68
|
**extra_opts)
|
|
@@ -116,6 +119,8 @@ module ReactiveComponent
|
|
|
116
119
|
end
|
|
117
120
|
private_class_method :raise_unsafe!
|
|
118
121
|
|
|
122
|
+
def self.signal_for(component_class, record) = { 'id' => record.id, 'dom_id' => component_class.dom_id_for(record) }
|
|
123
|
+
|
|
119
124
|
def self.broadcast_for(component_class, record, action:)
|
|
120
125
|
return unless component_class._subscribed_events.include?(action)
|
|
121
126
|
|
|
@@ -129,11 +134,11 @@ module ReactiveComponent
|
|
|
129
134
|
|
|
130
135
|
case action
|
|
131
136
|
when :update
|
|
132
|
-
|
|
137
|
+
data = component_class.notify? ? signal_for(component_class, record) : component_class.build_data(record)
|
|
138
|
+
|
|
139
|
+
Channel.broadcast_data(stream, action: :update, data: data)
|
|
133
140
|
when :destroy
|
|
134
|
-
Channel.broadcast_data(stream, action: :destroy, data:
|
|
135
|
-
'id' => record.id, 'dom_id' => component_class.dom_id_for(record)
|
|
136
|
-
})
|
|
141
|
+
Channel.broadcast_data(stream, action: :destroy, data: signal_for(component_class, record))
|
|
137
142
|
when :create
|
|
138
143
|
target = config&.dig(:prepend_target)
|
|
139
144
|
return unless target
|
|
@@ -145,11 +150,13 @@ module ReactiveComponent
|
|
|
145
150
|
end
|
|
146
151
|
|
|
147
152
|
class_methods do
|
|
148
|
-
def subscribes_to(attr_name, class_name: nil, only: %i[create update destroy], fields: nil)
|
|
153
|
+
def subscribes_to(attr_name, class_name: nil, only: %i[create update destroy], fields: nil, strategy: nil, later: true)
|
|
149
154
|
self._live_model_attr = attr_name.to_sym
|
|
150
155
|
self._live_model_class_name = class_name || attr_name.to_s.classify
|
|
151
156
|
self._subscribed_events = Array(only).map(&:to_sym)
|
|
152
157
|
self._subscribed_fields = fields && Array(fields).map(&:to_s)
|
|
158
|
+
self._strategy = strategy&.to_sym
|
|
159
|
+
self._broadcast_later = later
|
|
153
160
|
|
|
154
161
|
component_class = self
|
|
155
162
|
|
|
@@ -174,6 +181,8 @@ module ReactiveComponent
|
|
|
174
181
|
end
|
|
175
182
|
end
|
|
176
183
|
|
|
184
|
+
def notify? = _strategy == :notify
|
|
185
|
+
|
|
177
186
|
def live_model_class
|
|
178
187
|
_live_model_class_name&.constantize
|
|
179
188
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: reactive_component
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Przemyslaw Lusar
|
|
@@ -106,6 +106,7 @@ files:
|
|
|
106
106
|
- app/controllers/reactive_component/actions_controller.rb
|
|
107
107
|
- app/javascript/reactive_component/controllers/reactive_renderer_controller.js
|
|
108
108
|
- app/javascript/reactive_component/lib/reactive_renderer_utils.js
|
|
109
|
+
- app/jobs/reactive_component/broadcast_job.rb
|
|
109
110
|
- config/importmap.rb
|
|
110
111
|
- config/routes.rb
|
|
111
112
|
- lib/reactive_component.rb
|