@hotwired/turbo-rails 7.1.0 → 7.1.3

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.
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
- # Turbo
1
+ # <img src="assets/logo.png?sanitize=true" width="24" height="24" alt="Turbo"> Turbo
2
2
 
3
3
  [Turbo](https://turbo.hotwired.dev) gives you the speed of a single-page web application without having to write any JavaScript. Turbo accelerates links and form submissions without requiring you to change your server-side generated HTML. It lets you carve up a page into independent frames, which can be lazy-loaded and operate as independent components. And finally, helps you make partial page updates using just HTML and a set of CRUD-like container tags. These three techniques reduce the amount of custom JavaScript that many web applications need to write by an order of magnitude. And for the few dynamic bits that are left, you're invited to finish the job with [Stimulus](https://github.com/hotwired/stimulus).
4
4
 
5
- On top of accelerating web applications, Turbo was built from the ground-up to form the foundation of hybrid native applications. Write the navigational shell of your Android or iOS app using the standard platform tooling, then seamlessly fill in features from the web, following native navigation patterns. Not every mobile screen needs to be written in Swift or Kotlin to feel native. With Turbo, you spend less time wrangling JSON, waiting on app stores to approve updates, or reimplementing features you've already created in HTML.
5
+ On top of accelerating web applications, Turbo was built from the ground-up to form the foundation of hybrid native applications. Write the navigational shell of your [Android](https://github.com/hotwired/turbo-android) or [iOS](https://github.com/hotwired/turbo-ios) app using the standard platform tooling, then seamlessly fill in features from the web, following native navigation patterns. Not every mobile screen needs to be written in Swift or Kotlin to feel native. With Turbo, you spend less time wrangling JSON, waiting on app stores to approve updates, or reimplementing features you've already created in HTML.
6
6
 
7
7
  Turbo is a language-agnostic framework written in TypeScript, but this gem builds on top of those basics to make the integration with Rails as smooth as possible. You can deliver turbo updates via model callbacks over Action Cable, respond to controller actions with native navigation or standard redirects, and render turbo frames with helpers and layout-free responses.
8
8
 
9
9
 
10
- ## Turbo Drive
10
+ ## Navigate with Turbo Drive
11
11
 
12
- Turbo is a continuation of the ideas from the previous Turbolinks framework, and the heart of that past approach lives on as Turbo Drive. When installed, Turbo automatically intercepts all clicks on `<a href>` links to the same domain. When you click an eligible link, Turbo prevents the browser from following it. Instead, Turbo changes the browser’s URL using the History API, requests the new page using `fetch`, and then renders the HTML response.
12
+ Turbo is a continuation of the ideas from the previous [Turbolinks](https://github.com/turbolinks/turbolinks) framework, and the heart of that past approach lives on as Turbo Drive. When installed, Turbo automatically intercepts all clicks on `<a href>` links to the same domain. When you click an eligible link, Turbo prevents the browser from following it. Instead, Turbo changes the browser’s URL using the History API, requests the new page using `fetch`, and then renders the HTML response.
13
13
 
14
14
  During rendering, Turbo replaces the current `<body>` element outright and merges the contents of the `<head>` element. The JavaScript window and document objects, and the HTML `<html>` element, persist from one rendering to the next.
15
15
 
@@ -24,31 +24,64 @@ Turbo.session.drive = false
24
24
 
25
25
  Then you can use `data-turbo="true"` to enable Drive on a per-element basis.
26
26
 
27
+ [See documentation](https://turbo.hotwired.dev/handbook/drive).
27
28
 
28
- ## Turbo Frames
29
+ ## Decompose with Turbo Frames
29
30
 
30
- Turbo reinvents the old HTML technique of frames without any of the drawbacks that lead to developers abandoning it. With Turbo Frames, you can treat a subset of the page as its own component, where links and form submissions replace only that part. This removes an entire class of problems around partial interactivity that before would have required custom JavaScript.
31
+ Turbo reinvents the old HTML technique of frames without any of the drawbacks that lead to developers abandoning it. With Turbo Frames, **you can treat a subset of the page as its own component**, where links and form submissions **replace only that part**. This removes an entire class of problems around partial interactivity that before would have required custom JavaScript.
31
32
 
32
- It also makes it dead easy to carve a single page into smaller pieces that can all live on their own cache timeline. While the bulk of the page might easily be cached between users, a small personalized toolbar perhaps cannot. With Turbo::Frames, you can designate the toolbar as a frame, which will be lazy-loaded automatically by the publicly-cached root page. This means simpler pages, easier caching schemes with fewer dependent keys, and all without needing to write a lick of custom JavaScript.
33
+ It also makes it dead easy to carve a single page into smaller pieces that can all live on their own cache timeline. While the bulk of the page might easily be cached between users, a small personalized toolbar perhaps cannot. With Turbo::Frames, you can designate the toolbar as a frame, which will be **lazy-loaded automatically** by the publicly-cached root page. This means simpler pages, easier caching schemes with fewer dependent keys, and all without needing to write a lick of custom JavaScript.
33
34
 
35
+ This gem provides a `turbo_frame_tag` helper to create those frame.
34
36
 
35
- ## Turbo Streams
37
+ For instance:
38
+ ```erb
39
+ <%# app/views/todos/show.html.erb %>
40
+ <%= turbo_frame_tag @todo do %>
41
+ <p><%= @todo.description %></p>
36
42
 
37
- Partial page updates that are delivered asynchronously over a web socket connection is the hallmark of modern, reactive web applications. With Turbo Streams, you can get all of that modern goodness using the existing server-side HTML you're already rendering to deliver the first page load. With a set of simple CRUD container tags, you can send HTML fragments over the web socket (or in response to direct interactions), and see the page change in response to new data. Again, no need to construct an entirely separate API, no need to wrangle JSON, no need to reimplement the HTML construction in JavaScript. Take the HTML you're already making, wrap it in an update tag, and, voila, your page comes alive.
43
+ <%= link_to 'Edit this todo', edit_todo_path(@todo) %>
44
+ <% end %>
45
+
46
+ <%# app/views/todos/edit.html.erb %>
47
+ <%= turbo_frame_tag @todo do %>
48
+ <%= render "form" %>
49
+
50
+ <%= link_to 'Cancel', todo_path(@todo) %>
51
+ <% end %>
52
+ ```
53
+
54
+ When the user will click on the `Edit this todo` link, as direct response to this direct user interaction, the turbo frame will be replaced with the one in the `edit.html.erb` page automatically.
55
+
56
+ [See documentation](https://turbo.hotwired.dev/handbook/frames).
57
+
58
+ ## Come Alive with Turbo Streams
59
+
60
+ Partial page updates that are **delivered asynchronously over a web socket connection** is the hallmark of modern, reactive web applications. With Turbo Streams, you can get all of that modern goodness using the existing server-side HTML you're already rendering to deliver the first page load. With a set of simple CRUD container tags, you can send HTML fragments over the web socket (or in response to direct interactions), and see the page change in response to new data. Again, **no need to construct an entirely separate API**, **no need to wrangle JSON**, **no need to reimplement the HTML construction in JavaScript**. Take the HTML you're already making, wrap it in an update tag, and, voila, your page comes alive.
38
61
 
39
62
  With this Rails integration, you can create these asynchronous updates directly in response to your model changes. Turbo uses Active Jobs to provide asynchronous partial rendering and Action Cable to deliver those updates to subscribers.
40
63
 
64
+ This gem provides a `turbo_stream_from` helper to create a turbo stream.
65
+
66
+ ```erb
67
+ <%# app/views/todos/show.html.erb %>
68
+ <%= turbo_stream_from dom_id(@todo) %>
69
+
70
+ <%# Rest of show here %>
71
+ ```
72
+
73
+ [See documentation](https://turbo.hotwired.dev/handbook/streams).
41
74
 
42
75
  ## Installation
43
76
 
44
- The JavaScript for Turbo can either be run through the asset pipeline, which is included with this gem, or through the package that lives on NPM, through Webpacker.
77
+ This gem is automatically configured for applications made with Rails 7+ (unless --skip-hotwire is passed to the generator). But if you're on Rails 6, you can install it manually:
45
78
 
46
79
  1. Add the `turbo-rails` gem to your Gemfile: `gem 'turbo-rails'`
47
80
  2. Run `./bin/bundle install`
48
81
  3. Run `./bin/rails turbo:install`
49
82
  4. Run `./bin/rails turbo:install:redis` to change the development Action Cable adapter from Async (the default one) to Redis. The Async adapter does not support Turbo Stream broadcasting.
50
83
 
51
- Running `turbo:install` will install through NPM if Webpacker is installed in the application. Otherwise the asset pipeline version is used. To use the asset pipeline version, you must have `importmap-rails` installed first and listed higher in the Gemfile.
84
+ Running `turbo:install` will install through NPM if Node.js is used in the application. Otherwise the asset pipeline version is used. To use the asset pipeline version, you must have `importmap-rails` installed first and listed higher in the Gemfile.
52
85
 
53
86
  If you're using node and need to use the cable consumer, you can import [`cable`](https://github.com/hotwired/turbo-rails/blob/main/app/javascript/turbo/cable.js) (`import { cable } from "@hotwired/turbo-rails"`), but ensure that your application actually *uses* the members it `import`s when using this style (see [turbo-rails#48](https://github.com/hotwired/turbo-rails/issues/48)).
54
87
 
@@ -3486,6 +3486,19 @@ var cable = Object.freeze({
3486
3486
  subscribeTo: subscribeTo
3487
3487
  });
3488
3488
 
3489
+ function walk(obj) {
3490
+ if (!obj || typeof obj !== "object") return obj;
3491
+ if (obj instanceof Date || obj instanceof RegExp) return obj;
3492
+ if (Array.isArray(obj)) return obj.map(walk);
3493
+ return Object.keys(obj).reduce((function(acc, key) {
3494
+ var camel = key[0].toLowerCase() + key.slice(1).replace(/([A-Z]+)/g, (function(m, x) {
3495
+ return "_" + x.toLowerCase();
3496
+ }));
3497
+ acc[camel] = walk(obj[key]);
3498
+ return acc;
3499
+ }), {});
3500
+ }
3501
+
3489
3502
  class TurboCableStreamSourceElement extends HTMLElement {
3490
3503
  async connectedCallback() {
3491
3504
  connectStreamSource(this);
@@ -3508,13 +3521,24 @@ class TurboCableStreamSourceElement extends HTMLElement {
3508
3521
  const signed_stream_name = this.getAttribute("signed-stream-name");
3509
3522
  return {
3510
3523
  channel: channel,
3511
- signed_stream_name: signed_stream_name
3524
+ signed_stream_name: signed_stream_name,
3525
+ ...walk({
3526
+ ...this.dataset
3527
+ })
3512
3528
  };
3513
3529
  }
3514
3530
  }
3515
3531
 
3516
3532
  customElements.define("turbo-cable-stream-source", TurboCableStreamSourceElement);
3517
3533
 
3534
+ function overrideMethodWithFormmethod({detail: {formSubmission: {fetchRequest: fetchRequest, submitter: submitter}}}) {
3535
+ if (submitter && submitter.formMethod && fetchRequest.body.has("_method")) {
3536
+ fetchRequest.body.set("_method", submitter.formMethod);
3537
+ }
3538
+ }
3539
+
3540
+ addEventListener("turbo:submit-start", overrideMethodWithFormmethod);
3541
+
3518
3542
  var adapters = {
3519
3543
  logger: self.console,
3520
3544
  WebSocket: self.WebSocket
@@ -3533,8 +3557,6 @@ const now = () => (new Date).getTime();
3533
3557
 
3534
3558
  const secondsSince = time => (now() - time) / 1e3;
3535
3559
 
3536
- const clamp = (number, min, max) => Math.max(min, Math.min(max, number));
3537
-
3538
3560
  class ConnectionMonitor {
3539
3561
  constructor(connection) {
3540
3562
  this.visibilityDidChange = this.visibilityDidChange.bind(this);
@@ -3547,7 +3569,7 @@ class ConnectionMonitor {
3547
3569
  delete this.stoppedAt;
3548
3570
  this.startPolling();
3549
3571
  addEventListener("visibilitychange", this.visibilityDidChange);
3550
- logger.log(`ConnectionMonitor started. pollInterval = ${this.getPollInterval()} ms`);
3572
+ logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`);
3551
3573
  }
3552
3574
  }
3553
3575
  stop() {
@@ -3588,24 +3610,29 @@ class ConnectionMonitor {
3588
3610
  }), this.getPollInterval());
3589
3611
  }
3590
3612
  getPollInterval() {
3591
- const {min: min, max: max, multiplier: multiplier} = this.constructor.pollInterval;
3592
- const interval = multiplier * Math.log(this.reconnectAttempts + 1);
3593
- return Math.round(clamp(interval, min, max) * 1e3);
3613
+ const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor;
3614
+ const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10));
3615
+ const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate;
3616
+ const jitter = jitterMax * Math.random();
3617
+ return staleThreshold * 1e3 * backoff * (1 + jitter);
3594
3618
  }
3595
3619
  reconnectIfStale() {
3596
3620
  if (this.connectionIsStale()) {
3597
- logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, pollInterval = ${this.getPollInterval()} ms, time disconnected = ${secondsSince(this.disconnectedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`);
3621
+ logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`);
3598
3622
  this.reconnectAttempts++;
3599
3623
  if (this.disconnectedRecently()) {
3600
- logger.log("ConnectionMonitor skipping reopening recent disconnect");
3624
+ logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`);
3601
3625
  } else {
3602
3626
  logger.log("ConnectionMonitor reopening");
3603
3627
  this.connection.reopen();
3604
3628
  }
3605
3629
  }
3606
3630
  }
3631
+ get refreshedAt() {
3632
+ return this.pingedAt ? this.pingedAt : this.startedAt;
3633
+ }
3607
3634
  connectionIsStale() {
3608
- return secondsSince(this.pingedAt ? this.pingedAt : this.startedAt) > this.constructor.staleThreshold;
3635
+ return secondsSince(this.refreshedAt) > this.constructor.staleThreshold;
3609
3636
  }
3610
3637
  disconnectedRecently() {
3611
3638
  return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold;
@@ -3622,14 +3649,10 @@ class ConnectionMonitor {
3622
3649
  }
3623
3650
  }
3624
3651
 
3625
- ConnectionMonitor.pollInterval = {
3626
- min: 3,
3627
- max: 30,
3628
- multiplier: 5
3629
- };
3630
-
3631
3652
  ConnectionMonitor.staleThreshold = 6;
3632
3653
 
3654
+ ConnectionMonitor.reconnectionBackoffRate = .15;
3655
+
3633
3656
  var INTERNAL = {
3634
3657
  message_types: {
3635
3658
  welcome: "welcome",
@@ -3772,6 +3795,7 @@ Connection.prototype.events = {
3772
3795
  return this.monitor.recordPing();
3773
3796
 
3774
3797
  case message_types.confirmation:
3798
+ this.subscriptions.confirmSubscription(identifier);
3775
3799
  return this.subscriptions.notify(identifier, "connected");
3776
3800
 
3777
3801
  case message_types.rejection:
@@ -3839,9 +3863,47 @@ class Subscription {
3839
3863
  }
3840
3864
  }
3841
3865
 
3866
+ class SubscriptionGuarantor {
3867
+ constructor(subscriptions) {
3868
+ this.subscriptions = subscriptions;
3869
+ this.pendingSubscriptions = [];
3870
+ }
3871
+ guarantee(subscription) {
3872
+ if (this.pendingSubscriptions.indexOf(subscription) == -1) {
3873
+ logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`);
3874
+ this.pendingSubscriptions.push(subscription);
3875
+ } else {
3876
+ logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`);
3877
+ }
3878
+ this.startGuaranteeing();
3879
+ }
3880
+ forget(subscription) {
3881
+ logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`);
3882
+ this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription));
3883
+ }
3884
+ startGuaranteeing() {
3885
+ this.stopGuaranteeing();
3886
+ this.retrySubscribing();
3887
+ }
3888
+ stopGuaranteeing() {
3889
+ clearTimeout(this.retryTimeout);
3890
+ }
3891
+ retrySubscribing() {
3892
+ this.retryTimeout = setTimeout((() => {
3893
+ if (this.subscriptions && typeof this.subscriptions.subscribe === "function") {
3894
+ this.pendingSubscriptions.map((subscription => {
3895
+ logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`);
3896
+ this.subscriptions.subscribe(subscription);
3897
+ }));
3898
+ }
3899
+ }), 500);
3900
+ }
3901
+ }
3902
+
3842
3903
  class Subscriptions {
3843
3904
  constructor(consumer) {
3844
3905
  this.consumer = consumer;
3906
+ this.guarantor = new SubscriptionGuarantor(this);
3845
3907
  this.subscriptions = [];
3846
3908
  }
3847
3909
  create(channelName, mixin) {
@@ -3856,7 +3918,7 @@ class Subscriptions {
3856
3918
  this.subscriptions.push(subscription);
3857
3919
  this.consumer.ensureActiveConnection();
3858
3920
  this.notify(subscription, "initialized");
3859
- this.sendCommand(subscription, "subscribe");
3921
+ this.subscribe(subscription);
3860
3922
  return subscription;
3861
3923
  }
3862
3924
  remove(subscription) {
@@ -3874,6 +3936,7 @@ class Subscriptions {
3874
3936
  }));
3875
3937
  }
3876
3938
  forget(subscription) {
3939
+ this.guarantor.forget(subscription);
3877
3940
  this.subscriptions = this.subscriptions.filter((s => s !== subscription));
3878
3941
  return subscription;
3879
3942
  }
@@ -3881,7 +3944,7 @@ class Subscriptions {
3881
3944
  return this.subscriptions.filter((s => s.identifier === identifier));
3882
3945
  }
3883
3946
  reload() {
3884
- return this.subscriptions.map((subscription => this.sendCommand(subscription, "subscribe")));
3947
+ return this.subscriptions.map((subscription => this.subscribe(subscription)));
3885
3948
  }
3886
3949
  notifyAll(callbackName, ...args) {
3887
3950
  return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args)));
@@ -3895,6 +3958,15 @@ class Subscriptions {
3895
3958
  }
3896
3959
  return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined));
3897
3960
  }
3961
+ subscribe(subscription) {
3962
+ if (this.sendCommand(subscription, "subscribe")) {
3963
+ this.guarantor.guarantee(subscription);
3964
+ }
3965
+ }
3966
+ confirmSubscription(identifier) {
3967
+ logger.log(`Subscription confirmed ${identifier}`);
3968
+ this.findAll(identifier).map((subscription => this.guarantor.forget(subscription)));
3969
+ }
3898
3970
  sendCommand(subscription, command) {
3899
3971
  const {identifier: identifier} = subscription;
3900
3972
  return this.consumer.send({
@@ -3965,6 +4037,7 @@ var index = Object.freeze({
3965
4037
  INTERNAL: INTERNAL,
3966
4038
  Subscription: Subscription,
3967
4039
  Subscriptions: Subscriptions,
4040
+ SubscriptionGuarantor: SubscriptionGuarantor,
3968
4041
  adapters: adapters,
3969
4042
  createWebSocketURL: createWebSocketURL,
3970
4043
  logger: logger,
@@ -1,5 +1,6 @@
1
1
  import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"
2
2
  import { subscribeTo } from "./cable"
3
+ import snakeize from "./snakeize"
3
4
 
4
5
  class TurboCableStreamSourceElement extends HTMLElement {
5
6
  async connectedCallback() {
@@ -20,7 +21,7 @@ class TurboCableStreamSourceElement extends HTMLElement {
20
21
  get channel() {
21
22
  const channel = this.getAttribute("channel")
22
23
  const signed_stream_name = this.getAttribute("signed-stream-name")
23
- return { channel, signed_stream_name }
24
+ return { channel, signed_stream_name, ...snakeize({ ...this.dataset }) }
24
25
  }
25
26
  }
26
27
 
@@ -0,0 +1,5 @@
1
+ export function overrideMethodWithFormmethod({ detail: { formSubmission: { fetchRequest, submitter } } }) {
2
+ if (submitter && submitter.formMethod && fetchRequest.body.has("_method")) {
3
+ fetchRequest.body.set("_method", submitter.formMethod)
4
+ }
5
+ }
@@ -1,7 +1,10 @@
1
1
  import "./cable_stream_source_element"
2
+ import { overrideMethodWithFormmethod } from "./form_submissions"
2
3
 
3
4
  import * as Turbo from "@hotwired/turbo"
4
5
  export { Turbo }
5
6
 
6
7
  import * as cable from "./cable"
7
8
  export { cable }
9
+
10
+ addEventListener("turbo:submit-start", overrideMethodWithFormmethod)
@@ -0,0 +1,31 @@
1
+ // Based on https://github.com/nathan7/snakeize
2
+ //
3
+ // This software is released under the MIT license:
4
+ // Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ // this software and associated documentation files (the "Software"), to deal in
6
+ // the Software without restriction, including without limitation the rights to
7
+ // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8
+ // the Software, and to permit persons to whom the Software is furnished to do so,
9
+ // subject to the following conditions:
10
+
11
+ // The above copyright notice and this permission notice shall be included in all
12
+ // copies or substantial portions of the Software.
13
+
14
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ // FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17
+ // COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18
+ // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19
+ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ export default function walk (obj) {
21
+ if (!obj || typeof obj !== 'object') return obj;
22
+ if (obj instanceof Date || obj instanceof RegExp) return obj;
23
+ if (Array.isArray(obj)) return obj.map(walk);
24
+ return Object.keys(obj).reduce(function (acc, key) {
25
+ var camel = key[0].toLowerCase() + key.slice(1).replace(/([A-Z]+)/g, function (m, x) {
26
+ return '_' + x.toLowerCase();
27
+ });
28
+ acc[camel] = walk(obj[key]);
29
+ return acc;
30
+ }, {});
31
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotwired/turbo-rails",
3
- "version": "7.1.0",
3
+ "version": "7.1.3",
4
4
  "description": "The speed of a single-page web application without having to write any JavaScript",
5
5
  "module": "app/javascript/turbo/index.js",
6
6
  "main": "app/assets/javascripts/turbo.js",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@hotwired/turbo": "^7.1.0",
17
- "@rails/actioncable": "^6.0.0"
17
+ "@rails/actioncable": "^7.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@rollup/plugin-node-resolve": "^11.0.1",