funicular 0.0.1 → 0.1.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 +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- metadata +119 -8
data/docs/realtime.md
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
# ActionCable-compatible WebSocket
|
|
2
|
+
|
|
3
|
+
Bring real-time features to your application with Funicular's built-in, ActionCable-compatible WebSocket client. It allows your Pure Ruby frontend to communicate seamlessly with your Rails backend for live updates, notifications, chat, and more.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Overview](#overview)
|
|
8
|
+
- [Creating a Consumer](#creating-a-consumer)
|
|
9
|
+
- [Subscribing to Channels](#subscribing-to-channels)
|
|
10
|
+
- [Sending Messages](#sending-messages)
|
|
11
|
+
- [Handling Disconnection](#handling-disconnection)
|
|
12
|
+
- [Unsubscribing](#unsubscribing)
|
|
13
|
+
- [Complete Example: Chat Application](#complete-example-chat-application)
|
|
14
|
+
- [Best Practices](#best-practices)
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
The `Funicular::Cable` module provides a simple API to create consumers and subscribe to channels, handling all the complexities of the ActionCable protocol, including automatic reconnection.
|
|
19
|
+
|
|
20
|
+
### Protocol Compatibility
|
|
21
|
+
|
|
22
|
+
Funicular's WebSocket client is fully compatible with Rails ActionCable, supporting:
|
|
23
|
+
- Channel subscriptions
|
|
24
|
+
- Message broadcasting
|
|
25
|
+
- Channel actions (perform)
|
|
26
|
+
- Automatic reconnection
|
|
27
|
+
- Ping/pong keepalive
|
|
28
|
+
|
|
29
|
+
## Creating a Consumer
|
|
30
|
+
|
|
31
|
+
Create a consumer for your Rails app's cable endpoint:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
class ChatComponent < Funicular::Component
|
|
35
|
+
def component_mounted
|
|
36
|
+
# Create consumer pointing to Rails ActionCable endpoint
|
|
37
|
+
@consumer = Funicular::Cable.create_consumer("/cable")
|
|
38
|
+
|
|
39
|
+
# Subscribe to channels (see next section)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def component_unmounted
|
|
43
|
+
# Clean up (disconnect) when component is unmounted
|
|
44
|
+
@consumer&.disconnect
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Consumer URL
|
|
50
|
+
|
|
51
|
+
The URL can be:
|
|
52
|
+
- Relative: `/cable` (uses current host)
|
|
53
|
+
- Absolute: `ws://localhost:3000/cable` or `wss://example.com/cable`
|
|
54
|
+
|
|
55
|
+
## Subscribing to Channels
|
|
56
|
+
|
|
57
|
+
Subscribe to a channel and provide a callback to handle incoming messages:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
class ChatComponent < Funicular::Component
|
|
61
|
+
def component_mounted
|
|
62
|
+
@consumer = Funicular::Cable.create_consumer("/cable")
|
|
63
|
+
|
|
64
|
+
# Subscribe to ChatChannel with room parameter
|
|
65
|
+
@subscription = @consumer.subscriptions.create(
|
|
66
|
+
channel: "ChatChannel",
|
|
67
|
+
room: "lobby"
|
|
68
|
+
) do |message|
|
|
69
|
+
# This block is called whenever a message is received from the server
|
|
70
|
+
handle_message(message)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def handle_message(message)
|
|
75
|
+
puts "New message: #{message}"
|
|
76
|
+
patch(messages: state.messages + [message])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Channel Parameters
|
|
82
|
+
|
|
83
|
+
Pass parameters to identify the channel subscription:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# Single room chat
|
|
87
|
+
@subscription = @consumer.subscriptions.create(
|
|
88
|
+
channel: "ChatChannel",
|
|
89
|
+
room: "general"
|
|
90
|
+
) do |message|
|
|
91
|
+
# Handle message
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# User-specific notifications
|
|
95
|
+
@subscription = @consumer.subscriptions.create(
|
|
96
|
+
channel: "NotificationChannel",
|
|
97
|
+
user_id: current_user.id
|
|
98
|
+
) do |notification|
|
|
99
|
+
patch(notifications: state.notifications + [notification])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Post comments
|
|
103
|
+
@subscription = @consumer.subscriptions.create(
|
|
104
|
+
channel: "CommentsChannel",
|
|
105
|
+
post_id: props[:post_id]
|
|
106
|
+
) do |comment|
|
|
107
|
+
patch(comments: state.comments + [comment])
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Rails Backend (ActionCable Channel)
|
|
112
|
+
|
|
113
|
+
On the Rails side, define the corresponding channel:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# app/channels/chat_channel.rb
|
|
117
|
+
class ChatChannel < ApplicationCable::Channel
|
|
118
|
+
def subscribed
|
|
119
|
+
stream_from "chat_#{params[:room]}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unsubscribed
|
|
123
|
+
# Cleanup
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def speak(data)
|
|
127
|
+
# Broadcast message to all subscribers
|
|
128
|
+
ActionCable.server.broadcast(
|
|
129
|
+
"chat_#{params[:room]}",
|
|
130
|
+
{
|
|
131
|
+
user: current_user.username,
|
|
132
|
+
content: data["message"],
|
|
133
|
+
timestamp: Time.now.iso8601
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Sending Messages
|
|
141
|
+
|
|
142
|
+
Use the `perform` method to send messages/actions to the server:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class ChatComponent < Funicular::Component
|
|
146
|
+
def handle_send_message
|
|
147
|
+
# Send "speak" action to ChatChannel
|
|
148
|
+
@subscription.perform("speak", message: state.message_input)
|
|
149
|
+
|
|
150
|
+
# Clear input
|
|
151
|
+
patch(message_input: "")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def render
|
|
155
|
+
div do
|
|
156
|
+
# Message list
|
|
157
|
+
state.messages.each do |msg|
|
|
158
|
+
div { "#{msg['user']}: #{msg['content']}" }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Input form
|
|
162
|
+
input(
|
|
163
|
+
value: state.message_input,
|
|
164
|
+
oninput: ->(e) { patch(message_input: e.target[:value]) }
|
|
165
|
+
)
|
|
166
|
+
button(onclick: -> { handle_send_message }) { "Send" }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Action Parameters
|
|
173
|
+
|
|
174
|
+
The `perform` method sends an action to the Rails channel:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# Simple action
|
|
178
|
+
@subscription.perform("speak", message: "Hello!")
|
|
179
|
+
|
|
180
|
+
# Action with multiple parameters
|
|
181
|
+
@subscription.perform("update_typing_status", is_typing: true, user_id: 123)
|
|
182
|
+
|
|
183
|
+
# Action with complex data
|
|
184
|
+
@subscription.perform("edit_message", {
|
|
185
|
+
message_id: 456,
|
|
186
|
+
content: "Updated content",
|
|
187
|
+
edited_at: Time.now.to_s
|
|
188
|
+
})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The Rails channel receives these as method calls:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
class ChatChannel < ApplicationCable::Channel
|
|
195
|
+
def speak(data)
|
|
196
|
+
# data = { "message" => "Hello!" }
|
|
197
|
+
ActionCable.server.broadcast(...)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def update_typing_status(data)
|
|
201
|
+
# data = { "is_typing" => true, "user_id" => 123 }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Handling Disconnection
|
|
207
|
+
|
|
208
|
+
The consumer automatically reconnects when the connection is lost. You can handle connection events:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
class ChatComponent < Funicular::Component
|
|
212
|
+
def component_mounted
|
|
213
|
+
@consumer = Funicular::Cable.create_consumer("/cable")
|
|
214
|
+
|
|
215
|
+
# The consumer automatically reconnects on disconnection
|
|
216
|
+
# You can track connection state if needed
|
|
217
|
+
patch(connected: true)
|
|
218
|
+
|
|
219
|
+
@subscription = @consumer.subscriptions.create(
|
|
220
|
+
channel: "ChatChannel",
|
|
221
|
+
room: "lobby"
|
|
222
|
+
) do |message|
|
|
223
|
+
# Ensure we're still connected when handling messages
|
|
224
|
+
if state.connected
|
|
225
|
+
handle_message(message)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def component_unmounted
|
|
231
|
+
patch(connected: false)
|
|
232
|
+
@consumer&.disconnect
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Unsubscribing
|
|
238
|
+
|
|
239
|
+
Unsubscribe from a channel when no longer needed:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
class MultiChannelComponent < Funicular::Component
|
|
243
|
+
def switch_channel(new_room)
|
|
244
|
+
# Unsubscribe from old channel
|
|
245
|
+
@subscription&.unsubscribe
|
|
246
|
+
|
|
247
|
+
# Subscribe to new channel
|
|
248
|
+
@subscription = @consumer.subscriptions.create(
|
|
249
|
+
channel: "ChatChannel",
|
|
250
|
+
room: new_room
|
|
251
|
+
) do |message|
|
|
252
|
+
handle_message(message)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
patch(current_room: new_room)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def component_unmounted
|
|
259
|
+
# Clean up subscriptions
|
|
260
|
+
@subscription&.unsubscribe
|
|
261
|
+
@consumer&.disconnect
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Complete Example: Chat Application
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
class ChatComponent < Funicular::Component
|
|
270
|
+
def initialize_state
|
|
271
|
+
{
|
|
272
|
+
messages: [],
|
|
273
|
+
message_input: "",
|
|
274
|
+
current_room: "lobby",
|
|
275
|
+
online_users: [],
|
|
276
|
+
connected: false
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def component_mounted
|
|
281
|
+
# Create WebSocket consumer
|
|
282
|
+
@consumer = Funicular::Cable.create_consumer("/cable")
|
|
283
|
+
|
|
284
|
+
# Subscribe to chat channel
|
|
285
|
+
@chat_subscription = @consumer.subscriptions.create(
|
|
286
|
+
channel: "ChatChannel",
|
|
287
|
+
room: state.current_room
|
|
288
|
+
) do |message|
|
|
289
|
+
handle_chat_message(message)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Subscribe to presence channel
|
|
293
|
+
@presence_subscription = @consumer.subscriptions.create(
|
|
294
|
+
channel: "PresenceChannel",
|
|
295
|
+
room: state.current_room
|
|
296
|
+
) do |data|
|
|
297
|
+
handle_presence_update(data)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
patch(connected: true)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def component_unmounted
|
|
304
|
+
@chat_subscription&.unsubscribe
|
|
305
|
+
@presence_subscription&.unsubscribe
|
|
306
|
+
@consumer&.disconnect
|
|
307
|
+
patch(connected: false)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def handle_chat_message(message)
|
|
311
|
+
patch(messages: state.messages + [message])
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def handle_presence_update(data)
|
|
315
|
+
patch(online_users: data["users"])
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def handle_send_message
|
|
319
|
+
return if state.message_input.strip.empty?
|
|
320
|
+
|
|
321
|
+
@chat_subscription.perform("speak", {
|
|
322
|
+
message: state.message_input,
|
|
323
|
+
user: current_user.username
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
patch(message_input: "")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def handle_message_input(event)
|
|
330
|
+
new_value = event.target[:value]
|
|
331
|
+
patch(message_input: new_value)
|
|
332
|
+
|
|
333
|
+
# Send typing indicator
|
|
334
|
+
@chat_subscription.perform("typing", {
|
|
335
|
+
user: current_user.username,
|
|
336
|
+
is_typing: !new_value.empty?
|
|
337
|
+
})
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def render
|
|
341
|
+
div(class: "chat-container") do
|
|
342
|
+
# Header
|
|
343
|
+
div(class: "chat-header") do
|
|
344
|
+
h2 { "Chat Room: #{state.current_room}" }
|
|
345
|
+
span(class: state.connected ? "status-online" : "status-offline") do
|
|
346
|
+
state.connected ? "Connected" : "Disconnected"
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Online users
|
|
351
|
+
div(class: "online-users") do
|
|
352
|
+
h3 { "Online (#{state.online_users.length})" }
|
|
353
|
+
state.online_users.each do |user|
|
|
354
|
+
div(class: "user") { user }
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Message list
|
|
359
|
+
div(class: "messages", ref: :message_list) do
|
|
360
|
+
state.messages.each do |msg|
|
|
361
|
+
div(class: "message", key: msg["id"]) do
|
|
362
|
+
strong { "#{msg['user']}: " }
|
|
363
|
+
span { msg["content"] }
|
|
364
|
+
small(class: "timestamp") { msg["timestamp"] }
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Input form
|
|
370
|
+
div(class: "message-input") do
|
|
371
|
+
input(
|
|
372
|
+
value: state.message_input,
|
|
373
|
+
oninput: :handle_message_input,
|
|
374
|
+
placeholder: "Type a message...",
|
|
375
|
+
class: "input"
|
|
376
|
+
)
|
|
377
|
+
button(
|
|
378
|
+
onclick: -> { handle_send_message },
|
|
379
|
+
disabled: state.message_input.strip.empty? || !state.connected,
|
|
380
|
+
class: "send-button"
|
|
381
|
+
) { "Send" }
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def current_user
|
|
387
|
+
# Access current user from session or props
|
|
388
|
+
@current_user ||= props[:current_user]
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### Rails Backend
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
# app/channels/chat_channel.rb
|
|
397
|
+
class ChatChannel < ApplicationCable::Channel
|
|
398
|
+
def subscribed
|
|
399
|
+
stream_from "chat_#{params[:room]}"
|
|
400
|
+
PresenceChannel.broadcast_presence(params[:room])
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def unsubscribed
|
|
404
|
+
PresenceChannel.broadcast_presence(params[:room])
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def speak(data)
|
|
408
|
+
ActionCable.server.broadcast(
|
|
409
|
+
"chat_#{params[:room]}",
|
|
410
|
+
{
|
|
411
|
+
id: SecureRandom.uuid,
|
|
412
|
+
user: current_user.username,
|
|
413
|
+
content: data["message"],
|
|
414
|
+
timestamp: Time.now.iso8601
|
|
415
|
+
}
|
|
416
|
+
)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def typing(data)
|
|
420
|
+
ActionCable.server.broadcast(
|
|
421
|
+
"chat_#{params[:room]}_typing",
|
|
422
|
+
{
|
|
423
|
+
user: data["user"],
|
|
424
|
+
is_typing: data["is_typing"]
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# app/channels/presence_channel.rb
|
|
431
|
+
class PresenceChannel < ApplicationCable::Channel
|
|
432
|
+
def subscribed
|
|
433
|
+
stream_from "presence_#{params[:room]}"
|
|
434
|
+
add_user_to_room
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def unsubscribed
|
|
438
|
+
remove_user_from_room
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def self.broadcast_presence(room)
|
|
442
|
+
users = # ... fetch online users for room
|
|
443
|
+
ActionCable.server.broadcast(
|
|
444
|
+
"presence_#{room}",
|
|
445
|
+
{ users: users.map(&:username) }
|
|
446
|
+
)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
private
|
|
450
|
+
|
|
451
|
+
def add_user_to_room
|
|
452
|
+
# Track user presence
|
|
453
|
+
PresenceChannel.broadcast_presence(params[:room])
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def remove_user_from_room
|
|
457
|
+
# Remove user from tracking
|
|
458
|
+
PresenceChannel.broadcast_presence(params[:room])
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
## Best Practices
|
|
464
|
+
|
|
465
|
+
### 1. Clean Up Subscriptions
|
|
466
|
+
|
|
467
|
+
Always unsubscribe and disconnect in `component_unmounted`:
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
def component_unmounted
|
|
471
|
+
@subscription&.unsubscribe
|
|
472
|
+
@consumer&.disconnect
|
|
473
|
+
end
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### 2. Handle Connection State
|
|
477
|
+
|
|
478
|
+
Provide feedback when disconnected:
|
|
479
|
+
|
|
480
|
+
```ruby
|
|
481
|
+
def render
|
|
482
|
+
if !state.connected
|
|
483
|
+
div(class: "alert") { "Reconnecting..." }
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Rest of UI
|
|
487
|
+
end
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### 3. Use Specific Channels
|
|
491
|
+
|
|
492
|
+
Create focused channels instead of one large channel:
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
# ✅ Good: Specific channels
|
|
496
|
+
@consumer.subscriptions.create(channel: "ChatChannel", room: "lobby")
|
|
497
|
+
@consumer.subscriptions.create(channel: "NotificationChannel", user_id: user.id)
|
|
498
|
+
|
|
499
|
+
# ❌ Avoid: Generic catch-all channel
|
|
500
|
+
@consumer.subscriptions.create(channel: "AppChannel")
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
### 4. Validate Data on Server
|
|
504
|
+
|
|
505
|
+
Never trust client data:
|
|
506
|
+
|
|
507
|
+
```ruby
|
|
508
|
+
# Rails channel
|
|
509
|
+
def speak(data)
|
|
510
|
+
# ✅ Validate and sanitize
|
|
511
|
+
message = data["message"].to_s.strip[0..1000] # Limit length
|
|
512
|
+
return if message.empty?
|
|
513
|
+
|
|
514
|
+
ActionCable.server.broadcast(...)
|
|
515
|
+
end
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### 5. Use Keys for Dynamic Lists
|
|
519
|
+
|
|
520
|
+
When rendering messages from WebSocket, use `key` prop:
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
state.messages.map do |msg|
|
|
524
|
+
div(key: msg["id"]) { msg["content"] } # Efficient re-rendering
|
|
525
|
+
end
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### 6. Debounce Typing Indicators
|
|
529
|
+
|
|
530
|
+
```ruby
|
|
531
|
+
def handle_typing
|
|
532
|
+
# Clear existing timer
|
|
533
|
+
JS.global.clearTimeout(@typing_timer) if @typing_timer
|
|
534
|
+
|
|
535
|
+
# Send typing=true
|
|
536
|
+
@subscription.perform("typing", is_typing: true)
|
|
537
|
+
|
|
538
|
+
# Set timer to send typing=false after 1s of inactivity
|
|
539
|
+
@typing_timer = JS.global.setTimeout(1000) do
|
|
540
|
+
@subscription.perform("typing", is_typing: false)
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
```
|