Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent reloading pages when tapping same-page anchors #298

Merged
merged 12 commits into from
Jul 16, 2021

Conversation

domchristie
Copy link
Contributor

@domchristie domchristie commented Jul 1, 2021

This is a port of #57 and turbolinks/turbolinks#285 to resolve turbolinks/turbolinks#75, turbolinks/turbolinks#214, turbolinks/turbolinks#192, and #42

The Problem

Clicking on a link which references an anchor on the current page issues an unnecessary request. This can cause problems when linking to content which is lazily loaded (see https://agile-reef-88023.herokuapp.com/posts/1), as well as with analytics and skip links. Disabling Turbo completely on those links prevents the request, but because the view is not cached, this approach breaks the Back button.

The Solution

This PR detects whether the visit is to an anchor on the same page, and if so, prevents the request. It takes a snapshot and performs the scroll, bypassing any rendering and turbo:load triggering. A similar flow handles restorations to same-page anchors. Correct focusing is achieved by temporarily adding tabindex=-1 and calling focus on the target.

Notes

  1. I'm getting a reliably failing test (chrome on linux - FrameTests - evaluates frame script elements on each render (0.297s) AssertionError: expected 1 to equal 2), which doesn't seem to be related to these changes, and it's passing locally on my machine
  2. the Turbolinks version of this PR performed the same-page check in the session (née controller). This allowed the native adapters to check for same-page anchors and handle accordingly. The session is not so accessible from the Visit, so I'd welcome some tips on how to solve this (or whether it's even necessary now?).

Todo

  • Manual tests to double-check
  • Investigate Firefox bug when navigating back after first anchor click
  • Trigger hash change event when scrolling to same-page anchor

Fixes #42
Closes #152

@dhh
Copy link
Member

dhh commented Jul 1, 2021

That test is flaky. I reran the suite and it passed.

Reviewing whether we need something for the native adapters.

@dhh
Copy link
Member

dhh commented Jul 1, 2021

I think we're probably OK re: native apps. The iOS + Android adapters just call the visit code directly: https://github.com/hotwired/turbo-ios/blob/main/Source/WebView/turbo.js and https://github.com/hotwired/turbo-android/blob/main/turbo/src/main/assets/js/turbo_bridge.js.

@domchristie
Copy link
Contributor Author

Do we not need some kind of equivalent to: https://github.com/turbolinks/turbolinks-ios/pull/126/files#diff-db83eae543943038c85ab2f7a4c437adda02ed30411e803d0091f5a7fc32d0acR57 ? Feel free to ignore if I'm way out-of-date on this!

@dhh
Copy link
Member

dhh commented Jul 1, 2021

Let's double check with @jayohms who runs Mobile at Basecamp. He's out for a bit, but we'll make sure we're reading this right.

@domchristie
Copy link
Contributor Author

👍 to clarify my thoughts: I don't think that clicking on a same-page anchor in a native app should trigger a visit, Turbo should just perform the scrolling.

Navigating back from a same-page visit might be interesting. For iOS, this is probably less of an issue: normal visits get pushed onto a stack, and the back button controls the navigation between these screens. I don't think there'd be any expectation that tapping < Back would handle the screen's scroll position. For Android, with its hardware back button, the expectations might be different(?)

@dhh dhh added this to the 7.0.0 milestone Jul 1, 2021
@dhh dhh requested a review from jayohms July 1, 2021 23:50
@dhh dhh mentioned this pull request Jul 1, 2021
@terracatta
Copy link
Contributor

I just wanted to say that I tried this branch on my Rails app that was originally using the following code with Turbolinks to fix hash nav behavior.

// Workround for odd hash navigation behavior in turbolinks that messes
// with the pushstate history
document.addEventListener('turbolinks:click', function (event) {
  var anchorElement = event.target
  var isSamePageAnchor = (
    anchorElement.hash &&
    anchorElement.origin === window.location.origin &&
    anchorElement.pathname === window.location.pathname
  )

  if (isSamePageAnchor) {
    Turbolinks.controller.pushHistoryWithLocationAndRestorationIdentifier(
      event.data.url,
      Turbolinks.uuid()
    )
    window.dispatchEvent(new HashChangeEvent("hashchange"))
    event.preventDefault()
  }
})

I first ported the Rails app over to Turbo and then applied this PR. It worked great in the sense that no pages/requests were fired for hash links, but I had some stimulus code (data-action="hashchange@window->tabbed-nav#navigate") that also relied on the dispatching of the hashchange event to window, which this PR does not attempt to synthetically fire.

I forked this PR and just added a quick line of code to fire the event here kolide@fbed8a9#diff-78d8451f964182fd51330bac500ba6e71234f81aad9e8a57e682e723d0f517b3R269.

I'm likely going to refactor my tabbed nav in my Rails app to not have to rely on this event being fired, but I felt it was worth dropping a comment here in case you all felt firing the hashchange event was a good idea.

@jayohms
Copy link
Collaborator

jayohms commented Jul 13, 2021

👍 to clarify my thoughts: I don't think that clicking on a same-page anchor in a native app should trigger a visit, Turbo should just perform the scrolling.

@domchristie Agreed! This would be a nice change in the mobile apps.

Navigating back from a same-page visit might be interesting. For iOS, this is probably less of an issue: normal visits get pushed onto a stack, and the back button controls the navigation between these screens. I don't think there'd be any expectation that tapping < Back would handle the screen's scroll position. For Android, with its hardware back button, the expectations might be different(?)

I don't see this as problematic for iOS or Android. On Android, in most situations now, the system back button should work just like you'd expect the top toolbar back arrow to behave. Both mobile apps can behave the same way here: the same-page scroll is transparent to the apps and will not push onto the navigation stack.

the Turbolinks version of this PR performed the same-page check in the session (née controller). This allowed the native adapters to check for same-page anchors and handle accordingly. The session is not so accessible from the Visit, so I'd welcome some tips on how to solve this (or whether it's even necessary now?).

I tested this out in the mobile demo app here: https://github.com/hotwired/turbo-native-demo/compare/same-page-anchor

Some additional work will need to be done to prevent the mobile apps from following anchor visit navigation. I haven't spent enough time yet to see what a proper solution will look like, but it doesn't look like it'll be as straightforward as the proposed Turbolinks mobile adapter solutions, since it's wrapped up in the Visit class, like you mentioned.

If you have any ideas in mind, let me know! And I'll take I'll take closer look at it this week, too.

@jayohms
Copy link
Collaborator

jayohms commented Jul 13, 2021

The mobile adapters do have access to the currentVisit here: https://github.com/hotwired/turbo-android/blob/main/turbo/src/main/assets/js/turbo_bridge.js#L105

Here's a quick spike at the minimal set of changes (with clean up necessary) to support the mobile behavior I think we'd want:

--- a/src/core/drive/visit.ts
+++ b/src/core/drive/visit.ts
@@ -328,8 +328,8 @@ export class Visit implements FetchRequestDelegate {
     }
   }
 
-  scrollToAnchor() {
-    const anchor = getAnchor(this.location)
+  scrollToAnchor(location: URL = this.location) {
+    const anchor = getAnchor(location)
     if (anchor != null) {
       this.view.scrollToAnchor(anchor)
       return true
@@ -340,6 +340,11 @@ export class Visit implements FetchRequestDelegate {
     this.view.scrollToPosition({ x: 0, y: 0 })
   }
 
+  locationIsSamePageAnchor(location: URL) {
+    return getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) &&
+      getAnchor(location) != null
+  }
+

We can then combine that with this change in the mobile adapter: hotwired/turbo-android#175

One thing to note here is that the mobile apps will not see the new #anchor url when the scroll is peformed. The original location for the screen will be preserved. I don't think it should cause a negative side effect, though. And a positive is that a native screen location is immutable for each destination on the back stack, so we couldn't update the url without a new navigation visit, anyway.

@domchristie Thoughts?

@domchristie
Copy link
Contributor Author

Thanks @jayohms. It's looking like it's doable then :D

A possible slight complication here is that determining a same-page visit also requires the action

Having access to the currentVisit is handy, although I'm not 100% sure about asking the it if a proposed location is for same page. It kind of works if you consider a same-page click to be a child of the main visit; and it does have the benefit of access to the scrolling functions.

Another approach might be to go via Turbo.navigator. In the native bridge this might look like:

visitProposedToLocation(location, options) {
  if (window.Turbo && Turbo.navigator.locationWithActionIsSamePage(location, options.action)) {
    Turbo.navigator.view.scrollToAnchorFromLocation(location)
  } else {
    TurboSession.visitProposedToLocation(location.toString(), JSON.stringify(options))
  }
}

A bigger changeset (and the above breaks the law of demeter :/), but might work in terms of code organisation.

diff --git a/src/core/drive/navigator.ts b/src/core/drive/navigator.ts
index 998fb51..7b5d71d 100644
--- a/src/core/drive/navigator.ts
+++ b/src/core/drive/navigator.ts
@@ -2,7 +2,7 @@ import { Action, isAction } from "../types"
 import { FetchMethod } from "../../http/fetch_request"
 import { FetchResponse } from "../../http/fetch_response"
 import { FormSubmission } from "./form_submission"
-import { expandURL, Locatable } from "../url"
+import { expandURL, getAnchor, getRequestURL, Locatable } from "../url"
 import { Visit, VisitDelegate, VisitOptions } from "./visit"
 import { PageSnapshot } from "./page_snapshot"
 
@@ -119,6 +119,11 @@ export class Navigator {
     this.delegate.visitCompleted(visit)
   }
 
+  locationWithActionIsSamePage(location: URL, action: Action): boolean {
+    return getRequestURL(location) === getRequestURL(this.location) &&
+      (getAnchor(location) != null || action == "restore")
+  }
+
   // Visits
 
   get location() {
diff --git a/src/core/drive/visit.ts b/src/core/drive/visit.ts
index 5d8808e..2feee5b 100644
--- a/src/core/drive/visit.ts
+++ b/src/core/drive/visit.ts
@@ -2,7 +2,7 @@ import { Adapter } from "../native/adapter"
 import { FetchMethod, FetchRequest, FetchRequestDelegate } from "../../http/fetch_request"
 import { FetchResponse } from "../../http/fetch_response"
 import { History } from "./history"
-import { getAnchor, getRequestURL } from "../url"
+import { getAnchor } from "../url"
 import { PageSnapshot } from "./page_snapshot"
 import { Action } from "../types"
 import { uuid } from "../../util"
@@ -15,6 +15,7 @@ export interface VisitDelegate {
 
   visitStarted(visit: Visit): void
   visitCompleted(visit: Visit): void
+  locationWithActionIsSamePage(location: URL, action: Action): boolean
 }
 
 export enum TimingMetric {
@@ -90,11 +91,7 @@ export class Visit implements FetchRequestDelegate {
     this.referrer = referrer
     this.snapshotHTML = snapshotHTML
     this.response = response
-
-    this.isSamePage = (
-      getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) &&
-      (getAnchor(location) != null || this.action == "restore")
-    )
+    this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action)
   }
 
   get adapter() {
diff --git a/src/core/session.ts b/src/core/session.ts
index 8374962..7c5fb38 100644
--- a/src/core/session.ts
+++ b/src/core/session.ts
@@ -156,6 +156,10 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin
     this.notifyApplicationAfterPageLoad(visit.getTimingMetrics())
   }
 
+  locationWithActionIsSamePage(location: URL, action: Action): boolean {
+    return this.navigator.locationWithActionIsSamePage(location, action)
+  }
+
   // Form submit observer delegate
 
   willSubmitForm(form: HTMLFormElement, submitter?: HTMLElement): boolean {
diff --git a/src/core/view.ts b/src/core/view.ts
index e8ee0ae..eb01e55 100644
--- a/src/core/view.ts
+++ b/src/core/view.ts
@@ -1,6 +1,7 @@
 import { Renderer } from "./renderer"
 import { Snapshot } from "./snapshot"
 import { Position } from "./types"
+import { getAnchor } from "./url"
 
 export interface ViewDelegate<S extends Snapshot> {
   viewWillRenderSnapshot(snapshot: S, isPreview: boolean): void
@@ -21,7 +22,7 @@ export abstract class View<E extends Element, S extends Snapshot<E> = Snapshot<E
 
   // Scrolling
 
-  scrollToAnchor(anchor: string) {
+  scrollToAnchor(anchor: string | undefined) {
     const element = this.snapshot.getElementForAnchor(anchor)
     if (element) {
       this.scrollToElement(element)
@@ -31,6 +32,10 @@ export abstract class View<E extends Element, S extends Snapshot<E> = Snapshot<E
     }
   }
 
+  scrollToAnchorFromLocation(location: URL) {
+    this.scrollToAnchor(getAnchor(location))
+  }
+
   scrollToElement(element: Element) {
     element.scrollIntoView()
   }

@jayohms
Copy link
Collaborator

jayohms commented Jul 13, 2021

@domchristie I prefer the approach in your solution 👍. I realized that the currentVisit won't be available in the native adapter on the initial cold boot, so that would prevent same-page scrolling from working properly in the mobile apps in that situation.

Want to commit your solution and I can try it out and we see if there are any opportunities to improve the PR?

@domchristie domchristie force-pushed the skip-link-2 branch 2 times, most recently from 46184f1 to 40423ed Compare July 13, 2021 20:13
@domchristie
Copy link
Contributor Author

Want to commit your solution and I can try it out and we see if there are any opportunities to improve the PR?

@jayohms 👍 Done

@domchristie
Copy link
Contributor Author

@terracatta I've added the hash change event as a todo

@jayohms
Copy link
Collaborator

jayohms commented Jul 14, 2021

@domchristie The new implementation works great with hotwired/turbo-android#175 (and we can make the same change to the iOS adapter). Note that we won't be able to release new versions of the native adapters until this is merged and a new Turbo version is available, since the new navigator API is required.

@@ -119,6 +119,11 @@ export class Navigator {
this.delegate.visitCompleted(visit)
}

locationWithActionIsSamePage(location: URL, action: Action): boolean {
return getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) &&
(getAnchor(location) != null || action == "restore")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate on what situation the || action == "restore" condition is handling? I don't think this should ever affect the native apps, since restores are initiated by the apps themselves, but want to make sure.

Copy link
Contributor Author

@domchristie domchristie Jul 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jayohms sure, this refers to the changes in: bbb4d4b and 0800ec8. It prevents unnecessary requests when navigating back from a same-page anchor to a location with no anchor, when caching is disabled or has been cleared. e.g.

  1. Visit example.com which has Turbo caching disabled
  2. Click link to example.com#main
  3. Tap Back

Without this change, Turbo would re-request example.com because the anchor is undefined (and so would not be a same-page visit), and there's nothing in the cache. If the request URLs are equal, and the action is restore, we can be sure that the visit is a same-page one.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect, makes sense 👍

@jayohms
Copy link
Collaborator

jayohms commented Jul 14, 2021

@domchristie I'm good with this from the native app perspective. Thanks for doing this!

I'll make sure we ship native adapter updates once this is available.

@dhh
Copy link
Member

dhh commented Jul 14, 2021

So we're good to merge now, @domchristie?

@domchristie
Copy link
Contributor Author

So we're good to merge now, @domchristie?

@dhh just a few things to check off, including triggering the hashchange event, then I think we'll be good.

@domchristie
Copy link
Contributor Author

@dhh @terracatta 7387bad triggers a hashchange event on same-page visits.

A couple of point on this:

  • it does make use of the Visit process so won't be dispatched on native clients, which bypasses it (cc @jayohms)
  • it's fired twice when navigating Back/Forward: one that we trigger (with the correct old/new URLs) and one that the browser to automatically triggers (with the old/new URLs both set to the new location 🤷‍♂️)

seanpdoyle and others added 6 commits July 15, 2021 17:27
Closes hotwired#42

Navigate with a skip link within our Functional test suite, assert that
the correct element is scrolled to, the page's Location path and hash
are correct, and that the initial tab stop occurs after the skipped-to
content.
getAnchor returns an empty string for blank hashes (#) and undefined when no hash is set at all
domchristie and others added 6 commits July 15, 2021 17:27
when navigating back from an anchored location to a non-anchored location on the same page
when restoring same-page visits with caching disabled.

Requests shouldn't be issued when navigating back/forward on same-page visits; the scroll position should just be restored. However, when caching is disabled Turbo does not have a scroll position value to restore to.

This change prioritizes checking for same-page visits to prevent unnecessary requests, and when restoring a scroll position, tries to scroll to the anchor if it exists. It's effectively the same outcome as before, without the request.
@domchristie
Copy link
Contributor Author

domchristie commented Jul 16, 2021

@dhh OK, I've completed some manual tests using a build from this branch, and this demo: https://stripe-root-spatula.glitch.me/

There's still an issue in Firefox when navigating back to the initial location, e.g.:

  1. visit https://stripe-root-spatula.glitch.me/ on Firefox
  2. tap "Skip to content"
  3. tap Back (the scroll position should be that the top, but it does not change)

I have checked that scrollToTop called, but it seems to ignore it 🤷‍♂️

Update: this is a bug with Firefox when using history.scrollRestoration = 'manual' (minimal reproduction). It seems to be fixed in Firefox Nightly 🎉

@dhh
Copy link
Member

dhh commented Jul 16, 2021

Excellent work, Dom 👏

@acetinick
Copy link

acetinick commented Jul 23, 2021

hi @domchristie , I just tried upgrading to latest release and looks like its broken some existing behaviour I have across my app.

We were using Turbo.visit() to force redirect of same page to anchor after modal window will close to refresh just added record, and now with this PR doesn't trigger the visit.

Is there a way to override this behaviour or introduce a new action to Turbo.visit() ?

@marispro
Copy link

same to me. Would be good to know how to disable this.

@domchristie
Copy link
Contributor Author

@acetinick @marispro this should be fixed by #324: any visit with action: replace will load from the server. Hope that solves your cases.

@domchristie
Copy link
Contributor Author

There's still an issue in Firefox when navigating back to the initial location … this is a bug with Firefox when using history.scrollRestoration = 'manual' (minimal reproduction). It seems to be fixed in Firefox Nightly 🎉

Confirmed to be fixed in Firefox v92 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Turbo Drive breaks skip links Turbolinks should follow same-page anchor links without reloading the page
8 participants