turbo-rails 0.9.1 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +44 -11
- data/app/assets/javascripts/turbo.js +92 -18
- data/app/assets/javascripts/turbo.min.js +3 -3
- data/app/assets/javascripts/turbo.min.js.map +1 -1
- data/app/channels/turbo/streams_channel.rb +1 -1
- data/app/helpers/turbo/frames_helper.rb +14 -2
- data/app/helpers/turbo/streams_helper.rb +5 -0
- data/app/javascript/turbo/cable_stream_source_element.js +2 -1
- data/app/javascript/turbo/form_submissions.js +7 -0
- data/app/javascript/turbo/index.js +3 -0
- data/app/javascript/turbo/snakeize.js +31 -0
- data/app/models/concerns/turbo/broadcastable.rb +37 -13
- data/app/models/turbo/streams/tag_builder.rb +4 -4
- data/lib/tasks/turbo_tasks.rake +3 -1
- data/lib/turbo/engine.rb +2 -2
- data/lib/turbo/test_assertions.rb +10 -4
- data/lib/turbo/version.rb +1 -1
- metadata +19 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f196417e075fd43c1a1914181f1a24c03247d10ff7364af2fed45eaef75eb5eb
|
4
|
+
data.tar.gz: a1531a64d1960592308fd7049eda66e9d4c94dc9886d03009c8d40bf0693e746
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4bcb45c2e2db94aaadd921a85dea11002b5106c87b6258524b27a0a410ee366e9fef16bc5d0561b8f7064feea354599a125ab0e038bcc378d95ccef3af0eef7a
|
7
|
+
data.tar.gz: e49a079b7a38bd441c7b1d15cd70efac01a184b45956a953a584bb63714268a7df9d4879570452a09d60c0c73fced648d2ab5ae3a46f842b0789a00b00700565
|
data/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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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,25 @@ 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
|
+
const formMethod = submitter?.formMethod;
|
3536
|
+
if (formMethod && fetchRequest.body.has("_method")) {
|
3537
|
+
fetchRequest.body.set("_method", formMethod);
|
3538
|
+
}
|
3539
|
+
}
|
3540
|
+
|
3541
|
+
addEventListener("turbo:submit-start", overrideMethodWithFormmethod);
|
3542
|
+
|
3518
3543
|
var adapters = {
|
3519
3544
|
logger: self.console,
|
3520
3545
|
WebSocket: self.WebSocket
|
@@ -3533,8 +3558,6 @@ const now = () => (new Date).getTime();
|
|
3533
3558
|
|
3534
3559
|
const secondsSince = time => (now() - time) / 1e3;
|
3535
3560
|
|
3536
|
-
const clamp = (number, min, max) => Math.max(min, Math.min(max, number));
|
3537
|
-
|
3538
3561
|
class ConnectionMonitor {
|
3539
3562
|
constructor(connection) {
|
3540
3563
|
this.visibilityDidChange = this.visibilityDidChange.bind(this);
|
@@ -3547,7 +3570,7 @@ class ConnectionMonitor {
|
|
3547
3570
|
delete this.stoppedAt;
|
3548
3571
|
this.startPolling();
|
3549
3572
|
addEventListener("visibilitychange", this.visibilityDidChange);
|
3550
|
-
logger.log(`ConnectionMonitor started.
|
3573
|
+
logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`);
|
3551
3574
|
}
|
3552
3575
|
}
|
3553
3576
|
stop() {
|
@@ -3588,24 +3611,29 @@ class ConnectionMonitor {
|
|
3588
3611
|
}), this.getPollInterval());
|
3589
3612
|
}
|
3590
3613
|
getPollInterval() {
|
3591
|
-
const {
|
3592
|
-
const
|
3593
|
-
|
3614
|
+
const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor;
|
3615
|
+
const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10));
|
3616
|
+
const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate;
|
3617
|
+
const jitter = jitterMax * Math.random();
|
3618
|
+
return staleThreshold * 1e3 * backoff * (1 + jitter);
|
3594
3619
|
}
|
3595
3620
|
reconnectIfStale() {
|
3596
3621
|
if (this.connectionIsStale()) {
|
3597
|
-
logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts},
|
3622
|
+
logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`);
|
3598
3623
|
this.reconnectAttempts++;
|
3599
3624
|
if (this.disconnectedRecently()) {
|
3600
|
-
logger.log(
|
3625
|
+
logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`);
|
3601
3626
|
} else {
|
3602
3627
|
logger.log("ConnectionMonitor reopening");
|
3603
3628
|
this.connection.reopen();
|
3604
3629
|
}
|
3605
3630
|
}
|
3606
3631
|
}
|
3632
|
+
get refreshedAt() {
|
3633
|
+
return this.pingedAt ? this.pingedAt : this.startedAt;
|
3634
|
+
}
|
3607
3635
|
connectionIsStale() {
|
3608
|
-
return secondsSince(this.
|
3636
|
+
return secondsSince(this.refreshedAt) > this.constructor.staleThreshold;
|
3609
3637
|
}
|
3610
3638
|
disconnectedRecently() {
|
3611
3639
|
return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold;
|
@@ -3622,14 +3650,10 @@ class ConnectionMonitor {
|
|
3622
3650
|
}
|
3623
3651
|
}
|
3624
3652
|
|
3625
|
-
ConnectionMonitor.pollInterval = {
|
3626
|
-
min: 3,
|
3627
|
-
max: 30,
|
3628
|
-
multiplier: 5
|
3629
|
-
};
|
3630
|
-
|
3631
3653
|
ConnectionMonitor.staleThreshold = 6;
|
3632
3654
|
|
3655
|
+
ConnectionMonitor.reconnectionBackoffRate = .15;
|
3656
|
+
|
3633
3657
|
var INTERNAL = {
|
3634
3658
|
message_types: {
|
3635
3659
|
welcome: "welcome",
|
@@ -3772,6 +3796,7 @@ Connection.prototype.events = {
|
|
3772
3796
|
return this.monitor.recordPing();
|
3773
3797
|
|
3774
3798
|
case message_types.confirmation:
|
3799
|
+
this.subscriptions.confirmSubscription(identifier);
|
3775
3800
|
return this.subscriptions.notify(identifier, "connected");
|
3776
3801
|
|
3777
3802
|
case message_types.rejection:
|
@@ -3839,9 +3864,47 @@ class Subscription {
|
|
3839
3864
|
}
|
3840
3865
|
}
|
3841
3866
|
|
3867
|
+
class SubscriptionGuarantor {
|
3868
|
+
constructor(subscriptions) {
|
3869
|
+
this.subscriptions = subscriptions;
|
3870
|
+
this.pendingSubscriptions = [];
|
3871
|
+
}
|
3872
|
+
guarantee(subscription) {
|
3873
|
+
if (this.pendingSubscriptions.indexOf(subscription) == -1) {
|
3874
|
+
logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`);
|
3875
|
+
this.pendingSubscriptions.push(subscription);
|
3876
|
+
} else {
|
3877
|
+
logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`);
|
3878
|
+
}
|
3879
|
+
this.startGuaranteeing();
|
3880
|
+
}
|
3881
|
+
forget(subscription) {
|
3882
|
+
logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`);
|
3883
|
+
this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription));
|
3884
|
+
}
|
3885
|
+
startGuaranteeing() {
|
3886
|
+
this.stopGuaranteeing();
|
3887
|
+
this.retrySubscribing();
|
3888
|
+
}
|
3889
|
+
stopGuaranteeing() {
|
3890
|
+
clearTimeout(this.retryTimeout);
|
3891
|
+
}
|
3892
|
+
retrySubscribing() {
|
3893
|
+
this.retryTimeout = setTimeout((() => {
|
3894
|
+
if (this.subscriptions && typeof this.subscriptions.subscribe === "function") {
|
3895
|
+
this.pendingSubscriptions.map((subscription => {
|
3896
|
+
logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`);
|
3897
|
+
this.subscriptions.subscribe(subscription);
|
3898
|
+
}));
|
3899
|
+
}
|
3900
|
+
}), 500);
|
3901
|
+
}
|
3902
|
+
}
|
3903
|
+
|
3842
3904
|
class Subscriptions {
|
3843
3905
|
constructor(consumer) {
|
3844
3906
|
this.consumer = consumer;
|
3907
|
+
this.guarantor = new SubscriptionGuarantor(this);
|
3845
3908
|
this.subscriptions = [];
|
3846
3909
|
}
|
3847
3910
|
create(channelName, mixin) {
|
@@ -3856,7 +3919,7 @@ class Subscriptions {
|
|
3856
3919
|
this.subscriptions.push(subscription);
|
3857
3920
|
this.consumer.ensureActiveConnection();
|
3858
3921
|
this.notify(subscription, "initialized");
|
3859
|
-
this.
|
3922
|
+
this.subscribe(subscription);
|
3860
3923
|
return subscription;
|
3861
3924
|
}
|
3862
3925
|
remove(subscription) {
|
@@ -3874,6 +3937,7 @@ class Subscriptions {
|
|
3874
3937
|
}));
|
3875
3938
|
}
|
3876
3939
|
forget(subscription) {
|
3940
|
+
this.guarantor.forget(subscription);
|
3877
3941
|
this.subscriptions = this.subscriptions.filter((s => s !== subscription));
|
3878
3942
|
return subscription;
|
3879
3943
|
}
|
@@ -3881,7 +3945,7 @@ class Subscriptions {
|
|
3881
3945
|
return this.subscriptions.filter((s => s.identifier === identifier));
|
3882
3946
|
}
|
3883
3947
|
reload() {
|
3884
|
-
return this.subscriptions.map((subscription => this.
|
3948
|
+
return this.subscriptions.map((subscription => this.subscribe(subscription)));
|
3885
3949
|
}
|
3886
3950
|
notifyAll(callbackName, ...args) {
|
3887
3951
|
return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args)));
|
@@ -3895,6 +3959,15 @@ class Subscriptions {
|
|
3895
3959
|
}
|
3896
3960
|
return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined));
|
3897
3961
|
}
|
3962
|
+
subscribe(subscription) {
|
3963
|
+
if (this.sendCommand(subscription, "subscribe")) {
|
3964
|
+
this.guarantor.guarantee(subscription);
|
3965
|
+
}
|
3966
|
+
}
|
3967
|
+
confirmSubscription(identifier) {
|
3968
|
+
logger.log(`Subscription confirmed ${identifier}`);
|
3969
|
+
this.findAll(identifier).map((subscription => this.guarantor.forget(subscription)));
|
3970
|
+
}
|
3898
3971
|
sendCommand(subscription, command) {
|
3899
3972
|
const {identifier: identifier} = subscription;
|
3900
3973
|
return this.consumer.send({
|
@@ -3965,6 +4038,7 @@ var index = Object.freeze({
|
|
3965
4038
|
INTERNAL: INTERNAL,
|
3966
4039
|
Subscription: Subscription,
|
3967
4040
|
Subscriptions: Subscriptions,
|
4041
|
+
SubscriptionGuarantor: SubscriptionGuarantor,
|
3968
4042
|
adapters: adapters,
|
3969
4043
|
createWebSocketURL: createWebSocketURL,
|
3970
4044
|
logger: logger,
|