Skip to content

Commit 9fcb191

Browse files
committed
Turbo 8 compatibility
This is a breaking change – Turbo 8 requires a full HTML response to Frame requests, and the response head element needs to have the same data-turbo-track items as the current page or the frame will not be able to advance history. This change introduces a replacement for the default layout lambda that Turbo uses to achieve this, which will return a kpop/frame layout automatically for kpop frame requests. This is a breaking change because the layout needs to include all of the tracked header elements from the application, so we are changing the name of the layout to help ensure this happens.
1 parent bcf98bf commit 9fcb191

File tree

13 files changed

+137
-33
lines changed

13 files changed

+137
-33
lines changed

Gemfile.lock

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
PATH
22
remote: .
33
specs:
4-
katalyst-kpop (3.0.2)
4+
katalyst-kpop (3.1.0)
55
katalyst-html-attributes
6-
turbo-rails (< 2)
6+
turbo-rails
77
view_component
88

99
GEM
@@ -321,7 +321,7 @@ GEM
321321
stringio (3.1.0)
322322
thor (1.3.0)
323323
timeout (0.4.1)
324-
turbo-rails (1.5.0)
324+
turbo-rails (2.0.3)
325325
actionpack (>= 6.0.0)
326326
activejob (>= 6.0.0)
327327
railties (>= 6.0.0)

README.md

+28-2
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@ kpop provides helpers to add a basic scrim and modal target frame. These should
5454

5555
To show a modal you need to add content to the kpop turbo frame. You can do this in several ways:
5656
1. Use `content_for :kpop` in an HTML response to inject content into the kpop frame (see `yield :kpop` above)
57-
2. Use `layout "kpop"` in your controller to wrap your turbo response in a kpop frame
57+
2. Respond to a turbo frame request from the kpop frame component.
5858

5959
You can generate a link that will cause a modal to show using the `kpop_link_to` helper.
6060

6161
`kpop_link_to`'s are similar to a `link_to` in rails, but it will navigate to the given URL within the modal turbo
62-
frame. The targeted action will need to generate content in a `Kpop::FrameComponent`, e.g. using `layout "kpop"`.
62+
frame. The targeted action will need to generate content in a `Kpop::FrameComponent`, e.g. by responding to a turbo
63+
frame request with the ID `kpop`.
6364

6465
```html
6566
<!-- app/views/homepage/index.html.erb -->
@@ -73,6 +74,31 @@ frame. The targeted action will need to generate content in a `Kpop::FrameCompon
7374
<% end %>
7475
```
7576

77+
### Turbo Frame Layout
78+
79+
Turbo Frame navigation responses use a layout to add a basic document structure. The `turbo-rails` gem provides the
80+
`turbo_rails/frame` layout, and kpop provides a similar `kpop/frame` layout. If a turbo frame response is requested with
81+
the `kpop` ID, the `kpop/frame` layout will be used automatically. You can provide an alternative by setting `layout`
82+
in your controller, as usual.
83+
84+
Turbo 8 assumes that the frame response will be a complete HTML document, including a `<head>` and `<body>`, and the
85+
Turbo Visits use the response head to deduce whether the navigation can be snap-shotted or not. This logic lives in
86+
`@hotwire/turbo` in the `Turbo.Visit` constructor:
87+
88+
```javascript
89+
this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action);
90+
```
91+
92+
If the page is not the same, then the visit is not snap-shotted. Detection looks at turbo-tracked elements in the page
93+
head to make this decision, and history navigation with frames will not work unless the headers are compatible.
94+
95+
As a consequence of this logic, it's really important that the layout used for kpop frame responses is compatible with
96+
the application layout. Kpop provides a "sensible default" that includes stylesheets and javascripts, but if your
97+
application doesn't use the same structure as the Rails default, you'll need to provide your own layout.
98+
99+
If you're experiencing 'strange history behaviour', it's worth putting a breakpoint on the turbo `isSamePage`
100+
calculation to check that the headers are compatible.
101+
76102
## Development
77103

78104
Releases need to be distributed to rubygems.org and npmjs.org. To do this, you need to have accounts with both providers
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
# Kpop Frame Requests use a different layout than Turbo Frame requests.
4+
#
5+
# The layout used is <tt>kpop/frame.html.erb</tt>. If there's a need to customize this layout, an application can
6+
# supply its own (such as <tt>app/views/layouts/kpop/frame.html.erb</tt>) which will be used instead.
7+
#
8+
# This module is automatically included in <tt>ActionController::Base</tt>.
9+
module Katalyst
10+
module Kpop
11+
module FrameRequest
12+
extend ActiveSupport::Concern
13+
14+
class_methods do
15+
# Example:
16+
# require_kpop only: %i[new edit] { url_for(resource) }
17+
def require_kpop(**constraints, &fallback_location)
18+
define_method(:kpop_fallback_location, fallback_location) if fallback_location
19+
20+
before_action :require_kpop, **constraints
21+
end
22+
end
23+
24+
included do
25+
layout -> { turbo_frame_layout }
26+
end
27+
28+
private
29+
30+
def kpop_frame_request?
31+
turbo_frame_request_id == "kpop"
32+
end
33+
34+
def require_kpop
35+
redirect_back(fallback_location: kpop_fallback_location, status: :see_other) unless kpop_frame_request?
36+
end
37+
38+
def turbo_frame_layout
39+
if kpop_frame_request?
40+
"kpop/frame"
41+
elsif turbo_frame_request?
42+
"turbo_rails/frame"
43+
end
44+
end
45+
end
46+
end
47+
end

app/javascript/kpop/controllers/frame_controller.js

+23-11
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,9 @@ export default class Kpop__FrameController extends Controller {
228228
/**
229229
* Monkey patch for Turbo#FrameController.
230230
*
231-
* Intercept calls to navigateFrame(element, location) and ensures that src is
232-
* cleared if the frame is busy so that we don't restore an in-progress src on
233-
* restoration visits.
231+
* Intercept calls to linkClickIntercepted(element, location) and ensures
232+
* that src is cleared if the frame is busy so that we don't restore an
233+
* in-progress src on restoration visits.
234234
*
235235
* See Turbo issue: https://github.com/hotwired/turbo/issues/1055
236236
*
@@ -240,18 +240,30 @@ function installNavigationInterception(controller) {
240240
const TurboFrameController =
241241
controller.element.delegate.constructor.prototype;
242242

243-
if (TurboFrameController._navigateFrame) return;
244-
245-
TurboFrameController._navigateFrame = TurboFrameController.navigateFrame;
246-
TurboFrameController.navigateFrame = function (element, url, submitter) {
247-
const frame = this.findFrameElement(element, submitter);
243+
if (TurboFrameController._linkClickIntercepted) return;
244+
245+
TurboFrameController._linkClickIntercepted =
246+
TurboFrameController.linkClickIntercepted;
247+
TurboFrameController.linkClickIntercepted = function (element, location) {
248+
// #findFrameElement
249+
const id =
250+
element?.getAttribute("data-turbo-frame") ||
251+
this.element.getAttribute("target");
252+
let frame = document.getElementById(id);
253+
if (!(frame instanceof Turbo.FrameElement)) {
254+
frame = this.element;
255+
}
248256

249257
if (frame.kpop) {
250-
FrameModal.visit(url, frame.kpop, frame, () => {
251-
TurboFrameController._navigateFrame.call(this, element, url, submitter);
258+
FrameModal.visit(location, frame.kpop, frame, () => {
259+
TurboFrameController._linkClickIntercepted.call(
260+
this,
261+
element,
262+
location,
263+
);
252264
});
253265
} else {
254-
TurboFrameController._navigateFrame.call(this, element, url, submitter);
266+
TurboFrameController._linkClickIntercepted.call(this, element, location);
255267
}
256268
};
257269
}

app/javascript/kpop/modals/frame_modal.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export class FrameModal extends Modal {
3030

3131
/**
3232
* When a user clicks a kpop link, turbo intercepts the click and calls
33-
* navigateFrame on the turbo frame controller before setting the TurboFrame
33+
* #navigateFrame on the turbo frame controller before setting the TurboFrame
3434
* element's src attribute. KPOP intercepts this call and calls this method
3535
* first so we cancel problematic navigations that might cache invalid states.
3636
*

app/javascript/kpop/turbo_actions.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ Turbo.StreamActions.kpop_redirect_to = function () {
3232
);
3333
const a = document.createElement("A");
3434
a.setAttribute("data-turbo-action", "replace");
35-
this.targetElements[0].delegate.navigateFrame(a, this.getAttribute("href"));
35+
this.targetElements[0].delegate.linkClickIntercepted(
36+
a,
37+
this.getAttribute("href"),
38+
);
3639
} else {
3740
if (DEBUG)
3841
console.debug(`kpop: redirecting to ${this.getAttribute("href")}`);

app/views/layouts/kpop.html.erb

-4
This file was deleted.

app/views/layouts/kpop/frame.html.erb

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<html>
2+
<head>
3+
<%# include all turbo-track elements so turbo knows it can cache frame visits %>
4+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
5+
<%= javascript_importmap_tags %>
6+
<%= yield :head %>
7+
</head>
8+
<body>
9+
<%= render Kpop::FrameComponent.new do %>
10+
<%= yield %>
11+
<% end %>
12+
</body>
13+
</html>

katalyst-kpop.gemspec

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Gem::Specification.new do |spec|
44
spec.name = "katalyst-kpop"
5-
spec.version = "3.0.2"
5+
spec.version = "3.1.0"
66
spec.authors = ["Katalyst Interactive"]
77
spec.email = ["[email protected]"]
88

@@ -16,6 +16,6 @@ Gem::Specification.new do |spec|
1616
spec.metadata["rubygems_mfa_required"] = "true"
1717

1818
spec.add_dependency "katalyst-html-attributes"
19-
spec.add_dependency "turbo-rails", "< 2"
19+
spec.add_dependency "turbo-rails"
2020
spec.add_dependency "view_component"
2121
end

lib/katalyst/kpop/engine.rb

+8-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
module Katalyst
77
module Kpop
88
class Engine < ::Rails::Engine
9-
config.autoload_once_paths = %W(#{root}/app/helpers)
9+
isolate_namespace Katalyst::Kpop
10+
config.eager_load_namespaces << Katalyst::Kpop
11+
config.autoload_once_paths = %W(
12+
#{root}/app/helpers
13+
#{root}/app/controllers
14+
#{root}/app/controllers/concerns
15+
)
1016

1117
initializer "kpop.assets" do
1218
config.after_initialize do |app|
@@ -22,6 +28,7 @@ class Engine < ::Rails::Engine
2228
end
2329

2430
ActiveSupport.on_load(:action_controller_base) do
31+
include Katalyst::Kpop::FrameRequest
2532
helper Katalyst::Kpop::Engine.helpers
2633
end
2734
end

spec/dummy/app/controllers/children_controller.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
class ChildrenController < ParentsController
44
def new
5-
if turbo_frame_request?
6-
render layout: "kpop", locals: { child: @parent.children.build }
5+
if kpop_frame_request?
6+
render locals: { child: @parent.children.build }
77
else
88
render :show, locals: { child: @parent.children.build }
99
end

spec/dummy/app/controllers/modals_controller.rb

+5-5
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ class ModalsController < ApplicationController
44
before_action :delay_request
55

66
def show
7-
if request.headers["Turbo-Frame"] == "kpop"
8-
render "modals/frame", layout: "kpop"
7+
if kpop_frame_request?
8+
render "modals/frame"
99
else
10-
render "home/index", layout: "application", locals: { kpop: "modals/content" }
10+
render "home/index", locals: { kpop: "modals/content" }
1111
end
1212
end
1313

1414
def persistent
15-
unless request.headers["Turbo-Frame"] == "kpop"
15+
unless kpop_frame_request?
1616
@dismiss = root_path
17-
render "home/index", layout: "application", locals: { kpop: "modals/persistent" }
17+
render "home/index", locals: { kpop: "modals/persistent" }
1818
end
1919
end
2020

spec/requests/modals_controller_spec.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
let(:action) { get modal_path, headers: { "Turbo-Frame" => "kpop" } }
1717

1818
it { is_expected.to be_successful }
19-
it { is_expected.to have_rendered("layouts/kpop") }
19+
it { is_expected.to have_rendered("layouts/kpop/frame") }
2020
it { is_expected.to have_rendered("modals/frame") }
2121
end
2222
end

0 commit comments

Comments
 (0)