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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff983944887ba8b8d1e4a8d57ade9136c0cd90c2976fe8782f230171e230d336
4
- data.tar.gz: 2ef010d91c693bc7a7b2303ca3c33f846ab9afbaca256fc81867900ac9c6c23d
3
+ metadata.gz: f196417e075fd43c1a1914181f1a24c03247d10ff7364af2fed45eaef75eb5eb
4
+ data.tar.gz: a1531a64d1960592308fd7049eda66e9d4c94dc9886d03009c8d40bf0693e746
5
5
  SHA512:
6
- metadata.gz: 768102dbc9f24bfe7cc1b6b4ae82b4f807daf572a3236921e6a835af1ce2d0f4799785d7e584de423e54b6b85938fa46b94db7e0b61391a9ae1d709811b11797
7
- data.tar.gz: d10cebaf8f52440567f0d1ca106fd07734e54c409d87da091ec9f0f4a8f4af47452e9ea8011f8ca3e02b4c2f847f2fbc4bb268410a5c85383c729f366548e407
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, 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,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. pollInterval = ${this.getPollInterval()} ms`);
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 {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);
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}, pollInterval = ${this.getPollInterval()} ms, time disconnected = ${secondsSince(this.disconnectedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`);
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("ConnectionMonitor skipping reopening recent disconnect");
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.pingedAt ? this.pingedAt : this.startedAt) > this.constructor.staleThreshold;
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.sendCommand(subscription, "subscribe");
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.sendCommand(subscription, "subscribe")));
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,