turbo-rails 1.3.3 → 1.5.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 +47 -2
- data/Rakefile +15 -2
- data/app/assets/javascripts/turbo.js +139 -73
- data/app/assets/javascripts/turbo.min.js +3 -3
- data/app/assets/javascripts/turbo.min.js.map +1 -1
- data/app/controllers/turbo/frames/frame_request.rb +17 -7
- data/app/controllers/turbo/native/navigation.rb +16 -8
- data/app/helpers/turbo/frames_helper.rb +1 -1
- data/app/helpers/turbo/streams/action_helper.rb +14 -2
- data/app/javascript/turbo/cable_stream_source_element.js +13 -1
- data/app/javascript/turbo/fetch_requests.js +10 -1
- data/app/models/concerns/turbo/broadcastable.rb +35 -3
- data/app/models/turbo/streams/tag_builder.rb +2 -0
- data/app/views/layouts/turbo_rails/frame.html.erb +8 -0
- data/config/routes.rb +1 -1
- data/lib/install/turbo_with_bun.rb +9 -0
- data/lib/tasks/turbo_tasks.rake +35 -18
- data/lib/turbo/broadcastable/test_helper.rb +172 -0
- data/lib/turbo/engine.rb +15 -1
- data/lib/turbo/test_assertions/integration_test_assertions.rb +76 -0
- data/lib/turbo/test_assertions.rb +61 -5
- data/lib/turbo/version.rb +1 -1
- data/lib/turbo-rails.rb +2 -0
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 216ef0fb1d4b07f9231e52702506e6888495d7c15f07a0778eee156692c84d74
|
4
|
+
data.tar.gz: 964ea9a3f3111be08955e9c707a591941dfe7b5f943566716bcd26fbd3d9b19b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2f42f0e27dcb80e83b3bae71416b1c9c0cdd82ef462dfa96c57bc3fa2d2c88b548785d9e7b10945575ab53538cfc0cf99c49c3c7f5d6f4fdd0608f251ae38bad
|
7
|
+
data.tar.gz: 21bd467eed15705e4c46f3da62ae84d8466adc85dcd770abc837c1fbcb517ccca2d7a004f9c856bd2af1f6aed9f83df51825929de463428699a35a58c4b89846
|
data/README.md
CHANGED
@@ -4,7 +4,7 @@
|
|
4
4
|
|
5
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
|
-
Turbo is a language-agnostic framework written in
|
7
|
+
Turbo is a language-agnostic framework written in JavaScript, 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
10
|
## Navigate with Turbo Drive
|
@@ -18,7 +18,7 @@ Whereas Turbolinks previously just dealt with links, Turbo can now also process
|
|
18
18
|
Turbo Drive can be disabled on a per-element basis by annotating the element or any of its ancestors with `data-turbo="false"`. If you want Turbo Drive to be disabled by default, then you can adjust your import like this:
|
19
19
|
|
20
20
|
```js
|
21
|
-
import
|
21
|
+
import "@hotwired/turbo-rails"
|
22
22
|
Turbo.session.drive = false
|
23
23
|
```
|
24
24
|
|
@@ -55,6 +55,37 @@ When the user will click on the `Edit this todo` link, as direct response to thi
|
|
55
55
|
|
56
56
|
[See documentation](https://turbo.hotwired.dev/handbook/frames).
|
57
57
|
|
58
|
+
### A note on custom layouts
|
59
|
+
|
60
|
+
In order to render turbo frame requests without the application layout, Turbo registers a custom [layout method](https://api.rubyonrails.org/classes/ActionView/Layouts/ClassMethods.html#method-i-layout).
|
61
|
+
If your application uses custom layout resolution, you have to make sure to return `"turbo_rails/frame"` (or `false` for TurboRails < 1.4.0) for turbo frame requests:
|
62
|
+
|
63
|
+
```ruby
|
64
|
+
layout :custom_layout
|
65
|
+
|
66
|
+
def custom_layout
|
67
|
+
return "turbo_rails/frame" if turbo_frame_request?
|
68
|
+
|
69
|
+
# ... your custom layout logic
|
70
|
+
```
|
71
|
+
|
72
|
+
If you are using a custom, but "static" layout,
|
73
|
+
|
74
|
+
```ruby
|
75
|
+
layout "some_static_layout"
|
76
|
+
```
|
77
|
+
|
78
|
+
you **have** to change it to a layout method in order to conditionally return `false` for turbo frame requests:
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
layout :custom_layout
|
82
|
+
|
83
|
+
def custom_layout
|
84
|
+
return "turbo_rails/frame" if turbo_frame_request?
|
85
|
+
|
86
|
+
"some_static_layout"
|
87
|
+
```
|
88
|
+
|
58
89
|
## Come Alive with Turbo Streams
|
59
90
|
|
60
91
|
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.
|
@@ -96,11 +127,25 @@ import "@hotwired/turbo-rails"
|
|
96
127
|
|
97
128
|
You can watch [the video introduction to Hotwire](https://hotwired.dev/#screencast), which focuses extensively on demonstrating Turbo in a Rails demo. Then you should familiarize yourself with [Turbo handbook](https://turbo.hotwired.dev/handbook/introduction) to understand Drive, Frames, and Streams in-depth. Finally, dive into the code documentation by starting with [`Turbo::FramesHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/frames_helper.rb), [`Turbo::StreamsHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/streams_helper.rb), [`Turbo::Streams::TagBuilder`](https://github.com/hotwired/turbo-rails/blob/main/app/models/turbo/streams/tag_builder.rb), and [`Turbo::Broadcastable`](https://github.com/hotwired/turbo-rails/blob/main/app/models/concerns/turbo/broadcastable.rb).
|
98
129
|
|
130
|
+
### RubyDoc Documentation
|
131
|
+
|
132
|
+
For the API documentation covering this gem's classes and packages, [visit the
|
133
|
+
RubyDoc page][].
|
134
|
+
|
135
|
+
[visit the RubyDoc page](https://rubydoc.info/github/hotwired/turbo-rails/main)
|
99
136
|
|
100
137
|
## Compatibility with Rails UJS
|
101
138
|
|
102
139
|
Turbo can coexist with Rails UJS, but you need to take a series of upgrade steps to make it happen. See [the upgrading guide](https://github.com/hotwired/turbo-rails/blob/main/UPGRADING.md).
|
103
140
|
|
141
|
+
## Testing
|
142
|
+
|
143
|
+
|
144
|
+
The [`Turbo::TestAssertions`](./lib/turbo/test_assertions.rb) concern provides Turbo Stream test helpers that assert the presence or absence of `<turbo-stream>` elements in a rendered fragment of HTML. `Turbo::TestAssertions` are automatically included in [`ActiveSupport::TestCase`](https://edgeapi.rubyonrails.org/classes/ActiveSupport/TestCase.html) and depend on the presence of [`rails-dom-testing`](https://github.com/rails/rails-dom-testing/) assertions.
|
145
|
+
|
146
|
+
The [`Turbo::TestAssertions::IntegrationTestAssertions`](./lib/turbo/test_assertions/integration_test_assertions.rb) are built on top of `Turbo::TestAssertions`, and add support for passing a `status:` keyword. They are automatically included in [`ActionDispatch::IntegrationTest`](https://edgeguides.rubyonrails.org/testing.html#integration-testing).
|
147
|
+
|
148
|
+
The [`Turbo::Broadcastable::TestHelper`](./lib/turbo/broadcastable/test_helper.rb) concern provides Action Cable-aware test helpers that assert that `<turbo-stream>` elements were or were not broadcast over Action Cable. They are not automatically included. To use them in your tests, make sure to `include Turbo::Broadcastable::TestHelper`.
|
104
149
|
|
105
150
|
## Development
|
106
151
|
|
data/Rakefile
CHANGED
@@ -9,7 +9,20 @@ load "rails/tasks/statistics.rake"
|
|
9
9
|
Rake::TestTask.new do |test|
|
10
10
|
test.libs << "test"
|
11
11
|
test.test_files = FileList["test/**/*_test.rb"]
|
12
|
-
test.warning = false
|
13
12
|
end
|
14
13
|
|
15
|
-
task
|
14
|
+
task :test_prereq do
|
15
|
+
puts "Installing Ruby dependencies"
|
16
|
+
`bundle install`
|
17
|
+
|
18
|
+
puts "Installing JavaScript dependencies"
|
19
|
+
`yarn install`
|
20
|
+
|
21
|
+
puts "Building JavaScript"
|
22
|
+
`yarn build`
|
23
|
+
|
24
|
+
puts "Preparing test database"
|
25
|
+
`cd test/dummy; ./bin/rails db:test:prepare; cd ../..`
|
26
|
+
end
|
27
|
+
|
28
|
+
task default: [:test_prereq, :test]
|
@@ -56,13 +56,11 @@ function clickCaptured(event) {
|
|
56
56
|
|
57
57
|
(function() {
|
58
58
|
if ("submitter" in Event.prototype) return;
|
59
|
-
let prototype;
|
59
|
+
let prototype = window.Event.prototype;
|
60
60
|
if ("SubmitEvent" in window && /Apple Computer/.test(navigator.vendor)) {
|
61
61
|
prototype = window.SubmitEvent.prototype;
|
62
62
|
} else if ("SubmitEvent" in window) {
|
63
63
|
return;
|
64
|
-
} else {
|
65
|
-
prototype = window.Event.prototype;
|
66
64
|
}
|
67
65
|
addEventListener("click", clickCaptured, true);
|
68
66
|
Object.defineProperty(prototype, "submitter", {
|
@@ -82,14 +80,14 @@ var FrameLoadingStyle;
|
|
82
80
|
})(FrameLoadingStyle || (FrameLoadingStyle = {}));
|
83
81
|
|
84
82
|
class FrameElement extends HTMLElement {
|
83
|
+
static get observedAttributes() {
|
84
|
+
return [ "disabled", "complete", "loading", "src" ];
|
85
|
+
}
|
85
86
|
constructor() {
|
86
87
|
super();
|
87
88
|
this.loaded = Promise.resolve();
|
88
89
|
this.delegate = new FrameElement.delegateConstructor(this);
|
89
90
|
}
|
90
|
-
static get observedAttributes() {
|
91
|
-
return [ "disabled", "complete", "loading", "src" ];
|
92
|
-
}
|
93
91
|
connectedCallback() {
|
94
92
|
this.delegate.connect();
|
95
93
|
}
|
@@ -560,7 +558,7 @@ class FetchRequest {
|
|
560
558
|
credentials: "same-origin",
|
561
559
|
headers: this.headers,
|
562
560
|
redirect: "follow",
|
563
|
-
body: this.
|
561
|
+
body: this.isSafe ? null : this.body,
|
564
562
|
signal: this.abortSignal,
|
565
563
|
referrer: (_a = this.delegate.referrer) === null || _a === void 0 ? void 0 : _a.href
|
566
564
|
};
|
@@ -570,8 +568,8 @@ class FetchRequest {
|
|
570
568
|
Accept: "text/html, application/xhtml+xml"
|
571
569
|
};
|
572
570
|
}
|
573
|
-
get
|
574
|
-
return this.method
|
571
|
+
get isSafe() {
|
572
|
+
return this.method === FetchMethod.get;
|
575
573
|
}
|
576
574
|
get abortSignal() {
|
577
575
|
return this.abortController.signal;
|
@@ -633,9 +631,6 @@ class AppearanceObserver {
|
|
633
631
|
}
|
634
632
|
|
635
633
|
class StreamMessage {
|
636
|
-
constructor(fragment) {
|
637
|
-
this.fragment = importStreamElements(fragment);
|
638
|
-
}
|
639
634
|
static wrap(message) {
|
640
635
|
if (typeof message == "string") {
|
641
636
|
return new this(createDocumentFragment(message));
|
@@ -643,6 +638,9 @@ class StreamMessage {
|
|
643
638
|
return message;
|
644
639
|
}
|
645
640
|
}
|
641
|
+
constructor(fragment) {
|
642
|
+
this.fragment = importStreamElements(fragment);
|
643
|
+
}
|
646
644
|
}
|
647
645
|
|
648
646
|
StreamMessage.contentType = "text/vnd.turbo-stream.html";
|
@@ -691,6 +689,9 @@ function formEnctypeFromString(encoding) {
|
|
691
689
|
}
|
692
690
|
|
693
691
|
class FormSubmission {
|
692
|
+
static confirmMethod(message, _element, _submitter) {
|
693
|
+
return Promise.resolve(confirm(message));
|
694
|
+
}
|
694
695
|
constructor(delegate, formElement, submitter, mustRedirect = false) {
|
695
696
|
this.state = FormSubmissionState.initialized;
|
696
697
|
this.delegate = delegate;
|
@@ -704,9 +705,6 @@ class FormSubmission {
|
|
704
705
|
this.fetchRequest = new FetchRequest(this, this.method, this.location, this.body, this.formElement);
|
705
706
|
this.mustRedirect = mustRedirect;
|
706
707
|
}
|
707
|
-
static confirmMethod(message, _element, _submitter) {
|
708
|
-
return Promise.resolve(confirm(message));
|
709
|
-
}
|
710
708
|
get method() {
|
711
709
|
var _a;
|
712
710
|
const method = ((_a = this.submitter) === null || _a === void 0 ? void 0 : _a.getAttribute("formmethod")) || this.formElement.getAttribute("method") || "";
|
@@ -732,8 +730,8 @@ class FormSubmission {
|
|
732
730
|
var _a;
|
733
731
|
return formEnctypeFromString(((_a = this.submitter) === null || _a === void 0 ? void 0 : _a.getAttribute("formenctype")) || this.formElement.enctype);
|
734
732
|
}
|
735
|
-
get
|
736
|
-
return this.fetchRequest.
|
733
|
+
get isSafe() {
|
734
|
+
return this.fetchRequest.isSafe;
|
737
735
|
}
|
738
736
|
get stringFormData() {
|
739
737
|
return [ ...this.formData ].reduce(((entries, [name, value]) => entries.concat(typeof value == "string" ? [ [ name, value ] ] : [])), []);
|
@@ -761,7 +759,7 @@ class FormSubmission {
|
|
761
759
|
}
|
762
760
|
}
|
763
761
|
prepareRequest(request) {
|
764
|
-
if (!request.
|
762
|
+
if (!request.isSafe) {
|
765
763
|
const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token");
|
766
764
|
if (token) {
|
767
765
|
request.headers["X-CSRF-Token"] = token;
|
@@ -775,6 +773,7 @@ class FormSubmission {
|
|
775
773
|
var _a;
|
776
774
|
this.state = FormSubmissionState.waiting;
|
777
775
|
(_a = this.submitter) === null || _a === void 0 ? void 0 : _a.setAttribute("disabled", "");
|
776
|
+
this.setSubmitsWith();
|
778
777
|
dispatch("turbo:submit-start", {
|
779
778
|
target: this.formElement,
|
780
779
|
detail: {
|
@@ -822,6 +821,7 @@ class FormSubmission {
|
|
822
821
|
var _a;
|
823
822
|
this.state = FormSubmissionState.stopped;
|
824
823
|
(_a = this.submitter) === null || _a === void 0 ? void 0 : _a.removeAttribute("disabled");
|
824
|
+
this.resetSubmitterText();
|
825
825
|
dispatch("turbo:submit-end", {
|
826
826
|
target: this.formElement,
|
827
827
|
detail: Object.assign({
|
@@ -830,11 +830,35 @@ class FormSubmission {
|
|
830
830
|
});
|
831
831
|
this.delegate.formSubmissionFinished(this);
|
832
832
|
}
|
833
|
+
setSubmitsWith() {
|
834
|
+
if (!this.submitter || !this.submitsWith) return;
|
835
|
+
if (this.submitter.matches("button")) {
|
836
|
+
this.originalSubmitText = this.submitter.innerHTML;
|
837
|
+
this.submitter.innerHTML = this.submitsWith;
|
838
|
+
} else if (this.submitter.matches("input")) {
|
839
|
+
const input = this.submitter;
|
840
|
+
this.originalSubmitText = input.value;
|
841
|
+
input.value = this.submitsWith;
|
842
|
+
}
|
843
|
+
}
|
844
|
+
resetSubmitterText() {
|
845
|
+
if (!this.submitter || !this.originalSubmitText) return;
|
846
|
+
if (this.submitter.matches("button")) {
|
847
|
+
this.submitter.innerHTML = this.originalSubmitText;
|
848
|
+
} else if (this.submitter.matches("input")) {
|
849
|
+
const input = this.submitter;
|
850
|
+
input.value = this.originalSubmitText;
|
851
|
+
}
|
852
|
+
}
|
833
853
|
requestMustRedirect(request) {
|
834
|
-
return !request.
|
854
|
+
return !request.isSafe && this.mustRedirect;
|
835
855
|
}
|
836
856
|
requestAcceptsTurboStreamResponse(request) {
|
837
|
-
return !request.
|
857
|
+
return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement);
|
858
|
+
}
|
859
|
+
get submitsWith() {
|
860
|
+
var _a;
|
861
|
+
return (_a = this.submitter) === null || _a === void 0 ? void 0 : _a.getAttribute("data-turbo-submits-with");
|
838
862
|
}
|
839
863
|
}
|
840
864
|
|
@@ -1076,8 +1100,8 @@ class View {
|
|
1076
1100
|
}
|
1077
1101
|
|
1078
1102
|
class FrameView extends View {
|
1079
|
-
|
1080
|
-
this.element.innerHTML = ""
|
1103
|
+
missing() {
|
1104
|
+
this.element.innerHTML = `<strong class="turbo-frame-error">Content missing</strong>`;
|
1081
1105
|
}
|
1082
1106
|
get snapshot() {
|
1083
1107
|
return new Snapshot(this.element);
|
@@ -1232,16 +1256,16 @@ class FormLinkClickObserver {
|
|
1232
1256
|
}
|
1233
1257
|
|
1234
1258
|
class Bardo {
|
1235
|
-
constructor(delegate, permanentElementMap) {
|
1236
|
-
this.delegate = delegate;
|
1237
|
-
this.permanentElementMap = permanentElementMap;
|
1238
|
-
}
|
1239
1259
|
static async preservingPermanentElements(delegate, permanentElementMap, callback) {
|
1240
1260
|
const bardo = new this(delegate, permanentElementMap);
|
1241
1261
|
bardo.enter();
|
1242
1262
|
await callback();
|
1243
1263
|
bardo.leave();
|
1244
1264
|
}
|
1265
|
+
constructor(delegate, permanentElementMap) {
|
1266
|
+
this.delegate = delegate;
|
1267
|
+
this.permanentElementMap = permanentElementMap;
|
1268
|
+
}
|
1245
1269
|
enter() {
|
1246
1270
|
for (const id in this.permanentElementMap) {
|
1247
1271
|
const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id];
|
@@ -1352,10 +1376,6 @@ function elementIsFocusable(element) {
|
|
1352
1376
|
}
|
1353
1377
|
|
1354
1378
|
class FrameRenderer extends Renderer {
|
1355
|
-
constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
|
1356
|
-
super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender);
|
1357
|
-
this.delegate = delegate;
|
1358
|
-
}
|
1359
1379
|
static renderElement(currentElement, newElement) {
|
1360
1380
|
var _a;
|
1361
1381
|
const destinationRange = document.createRange();
|
@@ -1368,6 +1388,10 @@ class FrameRenderer extends Renderer {
|
|
1368
1388
|
currentElement.appendChild(sourceRange.extractContents());
|
1369
1389
|
}
|
1370
1390
|
}
|
1391
|
+
constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
|
1392
|
+
super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender);
|
1393
|
+
this.delegate = delegate;
|
1394
|
+
}
|
1371
1395
|
get shouldRender() {
|
1372
1396
|
return true;
|
1373
1397
|
}
|
@@ -1429,18 +1453,6 @@ function readScrollBehavior(value, defaultValue) {
|
|
1429
1453
|
}
|
1430
1454
|
|
1431
1455
|
class ProgressBar {
|
1432
|
-
constructor() {
|
1433
|
-
this.hiding = false;
|
1434
|
-
this.value = 0;
|
1435
|
-
this.visible = false;
|
1436
|
-
this.trickle = () => {
|
1437
|
-
this.setValue(this.value + Math.random() / 100);
|
1438
|
-
};
|
1439
|
-
this.stylesheetElement = this.createStylesheetElement();
|
1440
|
-
this.progressElement = this.createProgressElement();
|
1441
|
-
this.installStylesheetElement();
|
1442
|
-
this.setValue(0);
|
1443
|
-
}
|
1444
1456
|
static get defaultCSS() {
|
1445
1457
|
return unindent`
|
1446
1458
|
.turbo-progress-bar {
|
@@ -1458,6 +1470,18 @@ class ProgressBar {
|
|
1458
1470
|
}
|
1459
1471
|
`;
|
1460
1472
|
}
|
1473
|
+
constructor() {
|
1474
|
+
this.hiding = false;
|
1475
|
+
this.value = 0;
|
1476
|
+
this.visible = false;
|
1477
|
+
this.trickle = () => {
|
1478
|
+
this.setValue(this.value + Math.random() / 100);
|
1479
|
+
};
|
1480
|
+
this.stylesheetElement = this.createStylesheetElement();
|
1481
|
+
this.progressElement = this.createProgressElement();
|
1482
|
+
this.installStylesheetElement();
|
1483
|
+
this.setValue(0);
|
1484
|
+
}
|
1461
1485
|
show() {
|
1462
1486
|
if (!this.visible) {
|
1463
1487
|
this.visible = true;
|
@@ -1626,10 +1650,6 @@ function elementWithoutNonce(element) {
|
|
1626
1650
|
}
|
1627
1651
|
|
1628
1652
|
class PageSnapshot extends Snapshot {
|
1629
|
-
constructor(element, headSnapshot) {
|
1630
|
-
super(element);
|
1631
|
-
this.headSnapshot = headSnapshot;
|
1632
|
-
}
|
1633
1653
|
static fromHTMLString(html = "") {
|
1634
1654
|
return this.fromDocument(parseHTMLDocument(html));
|
1635
1655
|
}
|
@@ -1639,6 +1659,10 @@ class PageSnapshot extends Snapshot {
|
|
1639
1659
|
static fromDocument({head: head, body: body}) {
|
1640
1660
|
return new this(body, new HeadSnapshot(head));
|
1641
1661
|
}
|
1662
|
+
constructor(element, headSnapshot) {
|
1663
|
+
super(element);
|
1664
|
+
this.headSnapshot = headSnapshot;
|
1665
|
+
}
|
1642
1666
|
clone() {
|
1643
1667
|
const clonedElement = this.element.cloneNode(true);
|
1644
1668
|
const selectElements = this.element.querySelectorAll("select");
|
@@ -2143,10 +2167,11 @@ class BrowserAdapter {
|
|
2143
2167
|
|
2144
2168
|
class CacheObserver {
|
2145
2169
|
constructor() {
|
2170
|
+
this.selector = "[data-turbo-temporary]";
|
2171
|
+
this.deprecatedSelector = "[data-turbo-cache=false]";
|
2146
2172
|
this.started = false;
|
2147
|
-
this.
|
2148
|
-
const
|
2149
|
-
for (const element of staleElements) {
|
2173
|
+
this.removeTemporaryElements = _event => {
|
2174
|
+
for (const element of this.temporaryElements) {
|
2150
2175
|
element.remove();
|
2151
2176
|
}
|
2152
2177
|
};
|
@@ -2154,15 +2179,25 @@ class CacheObserver {
|
|
2154
2179
|
start() {
|
2155
2180
|
if (!this.started) {
|
2156
2181
|
this.started = true;
|
2157
|
-
addEventListener("turbo:before-cache", this.
|
2182
|
+
addEventListener("turbo:before-cache", this.removeTemporaryElements, false);
|
2158
2183
|
}
|
2159
2184
|
}
|
2160
2185
|
stop() {
|
2161
2186
|
if (this.started) {
|
2162
2187
|
this.started = false;
|
2163
|
-
removeEventListener("turbo:before-cache", this.
|
2188
|
+
removeEventListener("turbo:before-cache", this.removeTemporaryElements, false);
|
2164
2189
|
}
|
2165
2190
|
}
|
2191
|
+
get temporaryElements() {
|
2192
|
+
return [ ...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation ];
|
2193
|
+
}
|
2194
|
+
get temporaryElementsWithDeprecation() {
|
2195
|
+
const elements = document.querySelectorAll(this.deprecatedSelector);
|
2196
|
+
if (elements.length) {
|
2197
|
+
console.warn(`The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.`);
|
2198
|
+
}
|
2199
|
+
return [ ...elements ];
|
2200
|
+
}
|
2166
2201
|
}
|
2167
2202
|
|
2168
2203
|
class FrameRedirector {
|
@@ -2361,7 +2396,7 @@ class Navigator {
|
|
2361
2396
|
if (formSubmission == this.formSubmission) {
|
2362
2397
|
const responseHTML = await fetchResponse.responseHTML;
|
2363
2398
|
if (responseHTML) {
|
2364
|
-
const shouldCacheSnapshot = formSubmission.
|
2399
|
+
const shouldCacheSnapshot = formSubmission.isSafe;
|
2365
2400
|
if (!shouldCacheSnapshot) {
|
2366
2401
|
this.view.clearSnapshotCache();
|
2367
2402
|
}
|
@@ -3362,6 +3397,8 @@ var Turbo = Object.freeze({
|
|
3362
3397
|
StreamActions: StreamActions
|
3363
3398
|
});
|
3364
3399
|
|
3400
|
+
class TurboFrameMissingError extends Error {}
|
3401
|
+
|
3365
3402
|
class FrameController {
|
3366
3403
|
constructor(element) {
|
3367
3404
|
this.fetchResponseLoaded = _fetchResponse => {};
|
@@ -3458,26 +3495,14 @@ class FrameController {
|
|
3458
3495
|
try {
|
3459
3496
|
const html = await fetchResponse.responseHTML;
|
3460
3497
|
if (html) {
|
3461
|
-
const
|
3462
|
-
const
|
3463
|
-
if (
|
3464
|
-
|
3465
|
-
|
3466
|
-
|
3467
|
-
this.changeHistory();
|
3468
|
-
await this.view.render(renderer);
|
3469
|
-
this.complete = true;
|
3470
|
-
session.frameRendered(fetchResponse, this.element);
|
3471
|
-
session.frameLoaded(this.element);
|
3472
|
-
this.fetchResponseLoaded(fetchResponse);
|
3473
|
-
} else if (this.willHandleFrameMissingFromResponse(fetchResponse)) {
|
3474
|
-
console.warn(`A matching frame for #${this.element.id} was missing from the response, transforming into full-page Visit.`);
|
3475
|
-
this.visitResponse(fetchResponse.response);
|
3498
|
+
const document = parseHTMLDocument(html);
|
3499
|
+
const pageSnapshot = PageSnapshot.fromDocument(document);
|
3500
|
+
if (pageSnapshot.isVisitable) {
|
3501
|
+
await this.loadFrameResponse(fetchResponse, document);
|
3502
|
+
} else {
|
3503
|
+
await this.handleUnvisitableFrameResponse(fetchResponse);
|
3476
3504
|
}
|
3477
3505
|
}
|
3478
|
-
} catch (error) {
|
3479
|
-
console.error(error);
|
3480
|
-
this.view.invalidate();
|
3481
3506
|
} finally {
|
3482
3507
|
this.fetchResponseLoaded = () => {};
|
3483
3508
|
}
|
@@ -3529,7 +3554,6 @@ class FrameController {
|
|
3529
3554
|
this.resolveVisitPromise();
|
3530
3555
|
}
|
3531
3556
|
async requestFailedWithResponse(request, response) {
|
3532
|
-
console.error(response);
|
3533
3557
|
await this.loadResponse(response);
|
3534
3558
|
this.resolveVisitPromise();
|
3535
3559
|
}
|
@@ -3547,9 +3571,13 @@ class FrameController {
|
|
3547
3571
|
const frame = this.findFrameElement(formSubmission.formElement, formSubmission.submitter);
|
3548
3572
|
frame.delegate.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter);
|
3549
3573
|
frame.delegate.loadResponse(response);
|
3574
|
+
if (!formSubmission.isSafe) {
|
3575
|
+
session.clearCache();
|
3576
|
+
}
|
3550
3577
|
}
|
3551
3578
|
formSubmissionFailedWithResponse(formSubmission, fetchResponse) {
|
3552
3579
|
this.element.delegate.loadResponse(fetchResponse);
|
3580
|
+
session.clearCache();
|
3553
3581
|
}
|
3554
3582
|
formSubmissionErrored(formSubmission, error) {
|
3555
3583
|
console.error(error);
|
@@ -3579,6 +3607,22 @@ class FrameController {
|
|
3579
3607
|
willRenderFrame(currentElement, _newElement) {
|
3580
3608
|
this.previousFrameElement = currentElement.cloneNode(true);
|
3581
3609
|
}
|
3610
|
+
async loadFrameResponse(fetchResponse, document) {
|
3611
|
+
const newFrameElement = await this.extractForeignFrameElement(document.body);
|
3612
|
+
if (newFrameElement) {
|
3613
|
+
const snapshot = new Snapshot(newFrameElement);
|
3614
|
+
const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false);
|
3615
|
+
if (this.view.renderPromise) await this.view.renderPromise;
|
3616
|
+
this.changeHistory();
|
3617
|
+
await this.view.render(renderer);
|
3618
|
+
this.complete = true;
|
3619
|
+
session.frameRendered(fetchResponse, this.element);
|
3620
|
+
session.frameLoaded(this.element);
|
3621
|
+
this.fetchResponseLoaded(fetchResponse);
|
3622
|
+
} else if (this.willHandleFrameMissingFromResponse(fetchResponse)) {
|
3623
|
+
this.handleFrameMissingFromResponse(fetchResponse);
|
3624
|
+
}
|
3625
|
+
}
|
3582
3626
|
async visit(url) {
|
3583
3627
|
var _a;
|
3584
3628
|
const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams, this.element);
|
@@ -3634,6 +3678,10 @@ class FrameController {
|
|
3634
3678
|
session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier);
|
3635
3679
|
}
|
3636
3680
|
}
|
3681
|
+
async handleUnvisitableFrameResponse(fetchResponse) {
|
3682
|
+
console.warn(`The response (${fetchResponse.statusCode}) from <turbo-frame id="${this.element.id}"> is performing a full page visit due to turbo-visit-control.`);
|
3683
|
+
await this.visitResponse(fetchResponse.response);
|
3684
|
+
}
|
3637
3685
|
willHandleFrameMissingFromResponse(fetchResponse) {
|
3638
3686
|
this.element.setAttribute("complete", "");
|
3639
3687
|
const response = fetchResponse.response;
|
@@ -3654,6 +3702,14 @@ class FrameController {
|
|
3654
3702
|
});
|
3655
3703
|
return !event.defaultPrevented;
|
3656
3704
|
}
|
3705
|
+
handleFrameMissingFromResponse(fetchResponse) {
|
3706
|
+
this.view.missing();
|
3707
|
+
this.throwFrameMissingError(fetchResponse);
|
3708
|
+
}
|
3709
|
+
throwFrameMissingError(fetchResponse) {
|
3710
|
+
const message = `The response (${fetchResponse.statusCode}) did not contain the expected <turbo-frame id="${this.element.id}"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`;
|
3711
|
+
throw new TurboFrameMissingError(message);
|
3712
|
+
}
|
3657
3713
|
async visitResponse(response) {
|
3658
3714
|
const wrapped = new FetchResponse(response);
|
3659
3715
|
const responseHTML = await wrapped.responseHTML;
|
@@ -4048,7 +4104,9 @@ class TurboCableStreamSourceElement extends HTMLElement {
|
|
4048
4104
|
async connectedCallback() {
|
4049
4105
|
connectStreamSource(this);
|
4050
4106
|
this.subscription = await subscribeTo(this.channel, {
|
4051
|
-
received: this.dispatchMessageEvent.bind(this)
|
4107
|
+
received: this.dispatchMessageEvent.bind(this),
|
4108
|
+
connected: this.subscriptionConnected.bind(this),
|
4109
|
+
disconnected: this.subscriptionDisconnected.bind(this)
|
4052
4110
|
});
|
4053
4111
|
}
|
4054
4112
|
disconnectedCallback() {
|
@@ -4061,6 +4119,12 @@ class TurboCableStreamSourceElement extends HTMLElement {
|
|
4061
4119
|
});
|
4062
4120
|
return this.dispatchEvent(event);
|
4063
4121
|
}
|
4122
|
+
subscriptionConnected() {
|
4123
|
+
this.setAttribute("connected", "");
|
4124
|
+
}
|
4125
|
+
subscriptionDisconnected() {
|
4126
|
+
this.removeAttribute("connected");
|
4127
|
+
}
|
4064
4128
|
get channel() {
|
4065
4129
|
const channel = this.getAttribute("channel");
|
4066
4130
|
const signed_stream_name = this.getAttribute("signed-stream-name");
|
@@ -4113,7 +4177,9 @@ function determineFetchMethod(submitter, body, form) {
|
|
4113
4177
|
|
4114
4178
|
function determineFormMethod(submitter) {
|
4115
4179
|
if (submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) {
|
4116
|
-
if (submitter.
|
4180
|
+
if (submitter.name === "_method") {
|
4181
|
+
return submitter.value;
|
4182
|
+
} else if (submitter.hasAttribute("formmethod")) {
|
4117
4183
|
return submitter.formMethod;
|
4118
4184
|
} else {
|
4119
4185
|
return null;
|