Open jaredcwhite opened 3 years ago
Would a pair of document
- or window
-level suit your needs?
let scrollTop = 0
addEventListener("turbo:click", ({ target }) => {
if (target.hasAttribute("data-turbo-preserve-scroll")) {
scrollTop = document.scrollingElement.scrollTop
}
})
addEventListener("turbo:load", () => {
if (scrollTop) {
document.scrollingElement.scrollTo(0, scrollTop)
}
scrollTop = 0
})
@seanpdoyle That works in a pinch! 👏
Could you elaborate on how to implement this? I'm also looking for a similar solution, but I'm not exactly sure how to make it work. 🤔
We have a slightly different requirement. Instead of one HTML element, we have several elements for which we wanted to retain the scroll position. It is a Kanban board with multiple columns. The following worked for us:
let containerScrollTops = {};
addEventListener("turbo:click", () => {
document
.querySelectorAll("[data-turbo-preserve-scroll-container]")
.forEach((ele) => {
containerScrollTops[ele.dataset.turboPreserveScrollContainer] =
ele.scrollTop;
});
});
addEventListener("turbo:load", () => {
document
.querySelectorAll("[data-turbo-preserve-scroll-container]")
.forEach((ele) => {
const containerScrollTop =
containerScrollTops[ele.dataset.turboPreserveScrollContainer];
if (containerScrollTop) ele.scrollTo(0, containerScrollTop);
});
containerScrollTops = {};
});
We are essentially setting a unique value for data-turbo-preserve-scroll-container
for each such columns for which we would like to retain the scroll position.
Is there a solution to keep scroll position during form submission?
This solution doesn't seem to be working anymore. It seems as though turbo is scrolling back to the top after the turbo:load
event is fired. I can get it to work if I call scrollTo
inside a setTimeout
call, but this causes the page to flicker as it jumps to the top and then back down.
Our solution had been to set scroll in turbo:render
. This also stopped working, but we solved it today by setting Turbo.navigator.currentVisit.scrolled = true
on turbo:before-render
. Here's the full Stimulus controller (this is for navigating to a pane—which is a stand alone page, not Turboframe—that animates over the page one is navigating from):
import { Controller } from '@hotwired/stimulus'
import { Turbo } from '@hotwired/turbo-rails'
export default class extends Controller {
connect() {
if (window.previousPageWasAPaneLaunchPage) {
document.removeEventListener('turbo:before-render', disableTurboScroll)
document.removeEventListener('turbo:render', pageRendered)
}
document.addEventListener('turbo:visit', fetchRequested)
document.addEventListener('turbo:before-render', disableTurboScroll)
document.addEventListener('turbo:render', pageRendered)
}
disconnect() {
document.removeEventListener('turbo:visit', fetchRequested)
}
}
function fetchRequested() {
window.previousPageWasAPaneLaunchPage = true
window.paneStartPageScrollY = window.scrollY
}
function disableTurboScroll() {
if (!window.previousPageWasAPaneLaunchPage) Turbo.navigator.currentVisit.scrolled = true
}
function pageRendered() {
if (window.previousPageWasAPaneLaunchPage) window.paneStartPageScrollY = null
if (window.paneStartPageScrollY)
window.scrollTo(0, window.paneStartPageScrollY)
}
Here's the corresponding pane controller, which is added to the body
of the pane layout:
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
connect() {
window.previousPageWasAPaneLaunchPage = false
}
}
Our solution had been to set scroll in
turbo:render
. This also stopped working, ...
@daniel-nelson writing to Turbo.navigator.currentVisit.scrolled = true
reaches far into internal, private interfaces. Ideally, that wouldn't be necessary.
I have a hunch that the changes made in https://github.com/hotwired/turbo/commit/539b249d82f44f5a4fd6bd245fe1e45718b31508#diff-78d8451f964182fd51330bac500ba6e71234f81aad9e8a57e682e723d0f517b3R105-R106 are the cause of the issues in turbo:render
.
Could you try and boil down your use case to the original work-around's HTML and JS? I'd like to use that as a test case to revert whatever regressions were introduced in https://github.com/hotwired/turbo/commit/539b249d82f44f5a4fd6bd245fe1e45718b31508.
This solution doesn't seem to be working anymore. It seems as though turbo is scrolling back to the top after the
turbo:load
event is fired. I can get it to work if I callscrollTo
inside asetTimeout
call, but this causes the page to flicker as it jumps to the top and then back down.
@tobyzerner does that behavior occur when using the changes introduced in https://github.com/hotwired/turbo/pull/476? That PR attempts to better synchronize the after-load scrolling to occur within the next available animation frame. Your comment is from October 18, long before https://github.com/hotwired/turbo/commit/539b249d82f44f5a4fd6bd245fe1e45718b31508, so I'm curious if 476
would resolve it.
@daniel-nelson thanks for posting you comment! I was trying to solve exactly the same problem today while trying to migrate my project from turbolinks to turbo
@seanpdoyle In my app the majority of interactions are centered around action buttons. Since I didn't want to do granular updates to the interface I decided that I would just code them as very simple stimulus controllers that would make an ajax call and then reload page (or optionally redirect to a different one) on success.
Keeping the scroll position was challenge with the Turbolinks as well, I've found one of the solutions similar to what @daniel-nelson did, but in terms of turbolinks events.
This is how my reload page function looks now when adopted to turbo code:
export function reloadPage() {
var scrollPosition;
var focusId;
$(document).one("turbo:visit", function () {
scrollPosition = [window.scrollX, window.scrollY];
console.log("saving scroll position", scrollPosition)
focusId = document.activeElement.id;
});
$(document).one("turbo:before-render", function () {
Turbo.navigator.currentVisit.scrolled = true
});
$(document).one("turbo:render", function () {
if (scrollPosition) {
window.scrollTo.apply(window, scrollPosition);
scrollPosition = null;
}
if (focusId) {
document.getElementById(focusId).focus();
focusId = null;
}
});
Turbo.visit(location.toString(), { action: "replace" });
}
and that's the gist of my action controller:
export default class extends Controller {
run() {
let button = this.element
let request = this.element.dataset
let { name } = request
button.disabled = true
performRequest(name, request)
.then(json => {
if (json.redirect_to) {
Turbol.visit(json.redirect_to)
} else {
reloadPage()
}
})
.catch((err) => {
shoutError(err)
button.disabled = false
})
}
}
I think Turbo might be missing one of the possible actions for the visit
method - reload
that would reload the page while keeping the scroll position and that's the whole reason of the hack using the events.
What could be an optimal way to solve this on your opinion? I would be happy to do a pr
I think Turbo might be missing one of the possible actions for the
visit
method -reload
that would reload the page while keeping the scroll position and that's the whole reason of the hack using the events.What could be an optimal way to solve this on your opinion? I would be happy to do a pr
@can3p Turbo uses action: "restore"
internally. The Handbook explicitly discourages public usage:
Restoration visits have an action of restore and Turbo Drive reserves them for internal use. You should not attempt to annotate links or invoke
Turbo.visit
with an action ofrestore
.
If your Stimulus controller invokes Turbo.visit()
with { action: "restore" }
, does that achieve the outcome you're after?
No, restore
didn't help with my case unfortunately. Just tested it, when I use restore
instead of replace
in my reloadPage
function then:
restorationIdentifier
on every call for some reason and that results in cache miss@seanpdoyle Unfortunately #476 does not fix the workaround. The problem is that Turbo is setting the scroll position after turbo:load
is called, which that PR does not change.
Could you try and boil down your use case to the original work-around's HTML and JS? I'd like to use that as a test case to revert whatever regressions were introduced in 539b249.
@seanpdoyle Here is a minimal Rails project reproducing the issue: https://github.com/daniel-nelson/turbo_scroll_pane. The latest commit reverts the Turbo.navigator.currentVisit.scrolled = true
hack, resulting in the poor UX. If you want to see how it is supposed to work, check out the commit before that (the second commit in the repo).
@seanpdoyle Here is a minimal Rails project reproducing the issue: https://github.com/daniel-nelson/turbo_scroll_pane.
@seanpdoyle I should have specified in the project readme that you need to have a narrow viewport to see this properly. On desktop, we just show the new pane. When building this minimal app, I had a weird scaling issue when using the actual Chrome devices simulator. But it worked fine just dragging the window to a mobile width. I'll go update the readme now, but wanted to mention it here in case you already cloned.
Is there some native data-turbo-preserve-scroll
or not yet? I have some tabs and sidebar where the scroll position should be preserved. Other links should jump to the top.
Is there any easy way to do this?
And if so, how would you target just specific elements that you do not want to make the page scroll top?
For now I use a simple stimulus controller which is not the most efficient way:
import { Controller } from "stimulus";
export default class extends Controller {
initialize() {
this.scrollTop = 0;
}
connect() {
if (this.scrollTop) {
this.element.scrollTo(0, this.scrollTop);
this.scrollTop = 0;
}
}
position() {
this.scrollTop = this.element.scrollTop;
}
}
<nav id="sidebar-nav" data-controller="tree-scroll" data-action="scroll->tree-scroll#position" data-turbo-permanent >
...
</nav>
I believe that most should happen on disconnect but in my case the this.element.scrollTop
is always 0
on disconnect.
I created a small utility to freeze the scrolling for the next visit/render:
const freeze = () => {
// @ts-ignore
window.Turbo.navigator.currentVisit.scrolled = true;
document.removeEventListener("turbo:render", freeze);
};
export const freezeScrollOnNextRender = () => {
document.addEventListener("turbo:render", freeze);
};
Then I simply use it where needed:
import { Controller } from "@hotwired/stimulus";
import { freezeScrollOnNextRender } from "utils";
export default class extends Controller {
static targets = ["saveDraft"];
declare saveDraftTarget: HTMLButtonElement;
saveDraft() {
freezeScrollOnNextRender();
this.saveDraftTarget.click();
}
}
I could make it so every form submission kept the scroll by just have a submit action in my stimulus controller:
import { Controller } from "@hotwired/stimulus";
import { freezeScrollOnNextRender } from "utils";
export default class extends Controller {
static targets = ["form"];
declare formTarget: HTMLFormElement;
submitForm(event: Event) {
event.preventDefault();
freezeScrollOnNextRender();
this.formTarget.requestSubmit();
}
}
@dillonhafer can you elaborate more? I am having this issue. I don't get why when you submit a form it scrolls you all the way up to the top. In a landing page with a form at the end is just a ridiculous behavior. 😒
BTW I get [ERROR] Expected ";" but found "formTarget"
It is quite normal behavior to scroll the page to the top after a form submission, this is what browsers do. Turbo is merely following this standard behavior.
Sorry @dillonhafer I said it bad. It happens when the form have errors and not when the form is submitted.
It really needs to have something like InertiaJS https://inertiajs.com/links#preserve-scroll
Finally made it work using <turbo-frame id="root-path-to-post-action">
Finally made it work using
<turbo-frame id="root-path-to-post-action">
Could you elaborate a bit further, please? What exactly did you do?
@stasou
https://turbo.hotwired.dev/reference/frames
I thought the content of id
had to start with the form action URL, but as it turns out, any non-empty id
is fine.
Here's a Laravel code I wrote for a menu that reorders pages. Wrapping it in <turbo-frame>
allows the reordering without reloading. data-turbo="false"
is necessary for links.
<turbo-frame id="abc">
@foreach ($docpages as $docpage)
<div>
<span>{{ $docpage->title }}</span>
{{-- weird bug --}}
@if ($loop->index === 0) <form></form> @endif
<form action="{{ route('docpage.up', $docpage) }}" method="post">
@csrf
<input type="submit" value="︿" />
</form>
<form action="{{ route('docpage.down', $docpage) }}" method="post">
@csrf
<input type="submit" value="﹀" />
</form>
<a href="{{ route('docpage.edit', $docpage) }}" data-turbo="false">Éditer</a>
</div>
@endforeach
</turbo-frame>
This issue has been open since the week 1 of the project being open sourced, will it ever be addressed?
@mweitzel I never found a solution involving turbo directives or otherwise relevant javascript.
I did, however, achieve desired functionality just by adding a preventDefault()
directive to the click event.
I should mention, though, that my clicks trigger async actions, not just get routes, so I would test and see if this is enough.
@jaredcwhite You can achieve this using:
Turbo.scrollTop = 0;
Turbo.shouldPreserveScroll = false;
let shouldPreserveScroll = 0;
document.addEventListener("turbo:click", function(event) {
if (event.target.hasAttribute('data-turbo-preserve-scroll')) {
shouldPreserveScroll = true;
} else {
shouldPreserveScroll = false;
}
});
document.addEventListener("turbo:visit", function(event) {
if (shouldPreserveScroll) {
Turbo.scrollTop = document.documentElement.scrollTop;
} else {
Turbo.scrollTop = 0;
}
});
addEventListener("turbo:visit", () => {
Turbo.navigator.currentVisit.scrolled = true;
document.documentElement.scrollTop = Turbo.scrollTop;
});
For me I wanted to keep my left sidebar scroll intact when changing page. Here is what I have done
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="preserve-scroll"
export default class extends Controller {
connect() {
window.preserveScroll ||= {};
this.element.addEventListener('scroll', this.onElementScroll.bind(this));
this.restoreScroll();
}
disconnect() {
super.disconnect();
this.element.removeEventListener('scroll', this.onElementScroll);
}
onElementScroll() {
window.preserveScroll[this.element.id] = this.element.scrollTop;
}
restoreScroll() {
if (window.preserveScroll[this.element.id]) {
this.element.scrollTo(0, window.preserveScroll[this.element.id]);
}
}
}
Only requirement is to have a unique ID on my sidebar.
Before I added Hotwire, I had a form whose action
attribute included a fragment. This fragment got the browser to scroll down to the form after submission:
<form id="my-form" action="/path/to/form.htm#my-form">
This worked great. But, when I added Hotwire, and Turbo Drive takes over the form submission, it ignores the #my-form
portion of the URL when managing the application visits. Ideally, Turbo Drive would just do this; and, I think it would solve many of the problems discussed above.
This was my solution:
import * as Turbo from '@hotwired/turbo'
if (!window.scrollPositions) {
window.scrollPositions = {};
}
function preserveScroll () {
document.querySelectorAll("[data-preserve-scroll").forEach((element) => {
scrollPositions[element.id] = element.scrollTop;
})
}
function restoreScroll (event) {
document.querySelectorAll("[data-preserve-scroll").forEach((element) => {
element.scrollTop = scrollPositions[element.id];
})
if (!event.detail.newBody) return
// event.detail.newBody is the body element to be swapped in.
// https://turbo.hotwired.dev/reference/events
event.detail.newBody.querySelectorAll("[data-preserve-scroll").forEach((element) => {
element.scrollTop = scrollPositions[element.id];
})
}
window.addEventListener("turbo:before-cache", preserveScroll)
window.addEventListener("turbo:before-render", restoreScroll)
window.addEventListener("turbo:render", restoreScroll)
<nav id="sidebar" data-preserve-scroll>
<!-- stuff -->
</nav>
If you're submitting a form and want to maintain its scroll position, simply wrap it in a <turbo-frame id="form">
const TurboHelper = class {
constructor() {
if (!window.scrollPositions) {
window.scrollPositions = {};
}
document.addEventListener("turbo:click", (event) => {
if (this.isInsidePreserveScrollElement(event.target)) {
window.scrollPositions['page'] = window.scrollY;
}
});
document.addEventListener("turbo:before-render", () => {
if (window.scrollPositions['page']) {
requestAnimationFrame(() => {
window.scrollTo(0, window.scrollPositions['page']);
});
}
});
}
isInsidePreserveScrollElement(target) {
const preserveScrollElements = document.querySelectorAll('[data-preserve-scroll]');
for (let element of preserveScrollElements) {
if (element.contains(target)) {
return true;
}
}
return false;
}
}
// entry point file
import './../bootstrap';
import './../turbo-helper';
<nav role="navigation" class="flex items-center justify-between" id="pager" data-preserve-scroll>
<div class="flex-1 flex items-center justify-between">
<div>
<span class="relative z-0 inline-flex">
your links ....
</span>
</div>
</div>
</nav>
I don't see any movement on this issue, so want to explicitly state here: while there's a bunch of hacky workarounds for this issue, it's a crazy solution to fight Turbo and try to undo its scrolling. Like any hack, it's brittle, it's not a proper solution, and it might result in a bad UX in a pinch.
We need an option to simply disable the scrolling behavior for a particular request.
@andreyvit We'll get it in the upcoming Turbo 8, and with a way more powerful system (but apparently not too complex). See Turbo with Morphing: https://dev.37signals.com/a-happier-happy-path-in-turbo-with-morphing/
This works for me perfectly:
var scrollPositions = {};
document.addEventListener("turbo:before-render", function(){
document.querySelectorAll("[data-turbo-keep-scroll]").forEach(function(element){
scrollPositions[element.id] = element.scrollTop;
});
});
document.addEventListener("turbo:render", function(){
document.querySelectorAll("[data-turbo-keep-scroll]").forEach(function(element){
element.scrollTop = scrollPositions[element.id];
});
});
I have a long sidebar with nested elements and it can have a scroll. Normally when you choose something in the bottom and click on it, sidebar jumps to the top, and you don't see what you just have clicked. With this snippet it is kept in place, no flickering, works like a charm.
Worth noting regarding Turbo 8 and morphing — this new system does allow you to retain scroll position, but only for Page Refresh events, meaning only for when you're redirected back to the same page you're already observing. I don't believe Turbo 8 will allow you to retain scroll position when simply navigating from one page to another in a traditional "click a link, click another link" sense. Even retaining the scroll position in Turbo 8 is just a nice side-effect — the system is designed around morphing after a form POST/PATCH.
So, since this issue seems to be written more generically, I don't think Turbo 8 will / should close it.
Just in case it helps someone else, this issue for me was caused by duplicate IDs on text fields.
urbo 8 and morphing — this new system does allow you to retain scroll position, but only for Page Refresh events, meaning only for when you're redirected back to the same page you're already observing. I don't believe Turbo 8 will allow you to retain scroll position when simply navigating from one page to another in a traditional "click a link, click another link" sense. Even retaining the scroll position in Turbo 8 is just a nice side-effect — the system is designed around morphing after a form POST/PATCH.
So, since this issue seems to be written more generically, I don't think Turbo 8 will / should close it.
Yup I flagged this bug last week: https://github.com/hotwired/turbo-rails/issues/575#issuecomment-2000390645
Maybe I should open an issue
Probably not the answer anyone is really looking for this issue specifically, but we solved this by using Turbo Frames instead of reloading the entire page. You can put almost the entire page into a single frame and send it as part of the response. Turbo will replace only that frame and maintain scroll position.
Probably not the answer anyone is really looking for this issue specifically, but we solved this by using Turbo Frames instead of reloading the entire page. You can put almost the entire page into a single frame and send it as part of the response. Turbo will replace only that frame and maintain scroll position.
Was literally going to write the same thing. In our case we needed the entire page to reload without the scroll issue.
We solved this by splitting the page into it's logical two parts where all of the page is in one of either frames. Then in the controller we use turbo streams to simply re-render both frames. This achieves the same reload we needed before but without any scrolling.
The ONLY downside is having to put turbo specific code in the controller which sucks a bit, but in the end, if we're aiming for SPA like UX then it's probably good to be explicit about it 🤷
I'm using a modified version of @vmiguellima's workaround. My fix makes it so scroll is also preserved when pressing "back" button after regular navigation.
(function enableScrollPreservation() {
let scrollTop = 0;
let shouldPreserveScroll = false;
document.addEventListener("turbo:click", function (event) {
if ((event.target as HTMLDivElement).hasAttribute("data-turbo-preserve-scroll")) {
shouldPreserveScroll = true;
} else {
shouldPreserveScroll = false;
}
});
document.addEventListener("turbo:visit", function () {
if (shouldPreserveScroll) {
scrollTop = document.documentElement.scrollTop;
} else {
scrollTop = 0;
}
});
addEventListener("turbo:visit", () => {
if (shouldPreserveScroll) {
(window as any).Turbo.navigator.currentVisit.scrolled = true;
document.documentElement.scrollTop = scrollTop;
}
shouldPreserveScroll = false;
});
})();
I really wish this was built-in into Turbo :pray: but at least we have the workarkound ;)
In our case we do not want to maintain the original scroll position - but scroll to a specific HTML element once the page is rendered. For example: you submit a form and then the server responds with 422 as one form field did not validate. We then have a Stimulus controller that would automatically scroll the non-validating form field into view automatically.
However, this doesn't work as the Stimulus controller (using …targetConnected()
) will execute the scrolling first - and is then overridden by Turbo's scroll to top.
I feel like there should be a way within Turbo to be able to handle such situations - I mean scrolling to an erronenous form field after submission shouldn't be that uncommon? Yes, you can wrap the <form>
in a <turbo-frame>
, but I feel like this should be possible regardless of Turbo Frames.
@fritzmg Seconding the request to handle scrolling as part of Turbo navigation (maybe if the response redirect has an anchor, we could scroll to that anchor?) I guess the current canonical solution is to respond with <turbo-stream>
actions and defining a custom scroll action.
@dillonhafer can you elaborate more? I am having this issue. I don't get why when you submit a form it scrolls you all the way up to the top. In a landing page with a form at the end is just a ridiculous behavior. 😒
Me neither and I absolutely do not understand why it is the way it is. It is very bad UX!
Just to make sure: You are aware of this rather buried possibility?
If you're submitting a form and want to maintain its scroll position, simply wrap it in a
<turbo-frame id="form">
@dillonhafer can you elaborate more? I am having this issue. I don't get why when you submit a form it scrolls you all the way up to the top. In a landing page with a form at the end is just a ridiculous behavior. 😒
Me neither and I absolutely do not understand why it is the way it is. It is very bad UX!
I'm not arguing that it is a good or preferred idea, I'm merely stating that if you use a browser without javascript, and you submit a form, then the next page will be at the top when the browser renders the next document. I'm not arguing for it, I'm just saying browser have behaved that way since the 1990s.
@dillonhafer can you elaborate more? I am having this issue. I don't get why when you submit a form it scrolls you all the way up to the top. In a landing page with a form at the end is just a ridiculous behavior. 😒
Me neither and I absolutely do not understand why it is the way it is. It is very bad UX!
I'm not arguing that it is a good or preferred idea, I'm merely stating that if you use a browser without javascript, and you submit a form, then the next page will be at the top when the browser renders the next document. I'm not arguing for it, I'm just saying browser have behaved that way since the 1990s.
Yes, I am aware of that. It just shows again how developers have to fight the web standards in some way or another.
If you're submitting a form and want to maintain its scroll position, simply wrap it in a
<turbo-frame id="form">
Tried it and it leads to an error The response (200) did not contain the expected <turbo-frame id="contactform"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.
, although the form submit is doing fine. (I am new to the whole rails world btw.)
On a regular Drive page update, it's scrolling up to the top of the page. Normally that would be desired, but on some Drive links I want to maintain the current scroll position. I tried looking for a
data-turbo-preserve-scroll
option or something like that but couldn't find anything. I could use Frames in this scenario instead, but then I'd lose the URL history/back button/etc.