From 94c15746602c59e09a626fd89d5ac72bdeb7d049 Mon Sep 17 00:00:00 2001 From: Dom Christie Date: Sat, 20 Jul 2024 10:50:33 +0100 Subject: [PATCH 1/4] Tidy history popstate handling. Safari stopped triggering popstate on load from version 10 (~2016) so we can safely remove this workaround. https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event#the_history_stack --- src/core/drive/history.js | 39 +++++++++------------------------------ 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/src/core/drive/history.js b/src/core/drive/history.js index 45015b0cc..569b6a51d 100644 --- a/src/core/drive/history.js +++ b/src/core/drive/history.js @@ -1,11 +1,10 @@ -import { nextMicrotask, uuid } from "../../util" +import { uuid } from "../../util" export class History { location restorationIdentifier = uuid() restorationData = {} started = false - pageLoaded = false currentIndex = 0 constructor(delegate) { @@ -15,7 +14,6 @@ export class History { start() { if (!this.started) { addEventListener("popstate", this.onPopState, false) - addEventListener("load", this.onPageLoad, false) this.currentIndex = history.state?.turbo?.restorationIndex || 0 this.started = true this.replace(new URL(window.location.href)) @@ -25,7 +23,6 @@ export class History { stop() { if (this.started) { removeEventListener("popstate", this.onPopState, false) - removeEventListener("load", this.onPageLoad, false) this.started = false } } @@ -81,32 +78,14 @@ export class History { // Event handlers onPopState = (event) => { - if (this.shouldHandlePopState()) { - const { turbo } = event.state || {} - if (turbo) { - this.location = new URL(window.location.href) - const { restorationIdentifier, restorationIndex } = turbo - this.restorationIdentifier = restorationIdentifier - const direction = restorationIndex > this.currentIndex ? "forward" : "back" - this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction) - this.currentIndex = restorationIndex - } + const { turbo } = event.state || {} + if (turbo) { + this.location = new URL(window.location.href) + const { restorationIdentifier, restorationIndex } = turbo + this.restorationIdentifier = restorationIdentifier + const direction = restorationIndex > this.currentIndex ? "forward" : "back" + this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction) + this.currentIndex = restorationIndex } } - - onPageLoad = async (_event) => { - await nextMicrotask() - this.pageLoaded = true - } - - // Private - - shouldHandlePopState() { - // Safari dispatches a popstate event after window's load event, ignore it - return this.pageIsLoaded() - } - - pageIsLoaded() { - return this.pageLoaded || document.readyState == "complete" - } } From 7adbf9f798a57535dea1cd2e7ef8c113be7903c2 Mon Sep 17 00:00:00 2001 From: Dom Christie Date: Sat, 20 Jul 2024 21:05:00 +0100 Subject: [PATCH 2/4] Let browsers handle same-page anchor clicks --- src/util.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util.js b/src/util.js index 57c48ea6a..fe96935a6 100644 --- a/src/util.js +++ b/src/util.js @@ -233,7 +233,7 @@ export function doesNotTargetIFrame(name) { } export function findLinkFromClickTarget(target) { - return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") + return findClosestRecursively(target, "a[href]:not([href^='#']):not([target^=_]):not([download])") } export function getLocationForLink(link) { From 4ba8aea6a106b65ff99dad242d137c792b96a811 Mon Sep 17 00:00:00 2001 From: Dom Christie Date: Sat, 20 Jul 2024 21:18:51 +0100 Subject: [PATCH 3/4] Handle same-page anchor clicks by checking for absence of popstate data. Absent popstate event data suggests that the event originates from a same-page anchor click. Reconcile the empty state by incrementing the History's currentIndex, replacing the null history entry, setting the View's lastRenderedLocation, and then caching it --- src/core/drive/history.js | 11 +++++++---- src/core/session.js | 12 +++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/core/drive/history.js b/src/core/drive/history.js index 569b6a51d..16f11da53 100644 --- a/src/core/drive/history.js +++ b/src/core/drive/history.js @@ -78,14 +78,17 @@ export class History { // Event handlers onPopState = (event) => { - const { turbo } = event.state || {} - if (turbo) { - this.location = new URL(window.location.href) - const { restorationIdentifier, restorationIndex } = turbo + this.location = new URL(window.location.href) + + if (event.state?.turbo) { + const { restorationIdentifier, restorationIndex } = event.state.turbo this.restorationIdentifier = restorationIdentifier const direction = restorationIndex > this.currentIndex ? "forward" : "back" this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction) this.currentIndex = restorationIndex + } else { + this.currentIndex++ + this.delegate.historyPoppedWithEmptyState(this.location) } } } diff --git a/src/core/session.js b/src/core/session.js index 1047d4463..1e1ae92ed 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -190,6 +190,10 @@ export class Session { } } + historyPoppedWithEmptyState(location) { + this.#reconcileEmptyHistoryEntry(location) + } + // Scroll observer delegate scrollPositionChanged(position) { @@ -233,7 +237,7 @@ export class Session { // Navigator delegate allowsVisitingLocationWithAction(location, action) { - return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location) + return this.applicationAllowsVisitingLocation(location) } visitProposedToLocation(location, options) { @@ -469,6 +473,12 @@ export class Session { get snapshot() { return this.view.snapshot } + + #reconcileEmptyHistoryEntry(location) { + this.history.replace(location) + this.view.lastRenderedLocation = location + this.view.cacheSnapshot() + } } // Older versions of the Turbo Native adapters referenced the From a262b720ba082448787047582bce7c585538e6d7 Mon Sep 17 00:00:00 2001 From: Dom Christie Date: Sat, 20 Jul 2024 21:19:49 +0100 Subject: [PATCH 4/4] Clean up unnecessary code now that same-page link clicks are handled outside a Visit --- src/core/drive/navigator.js | 18 ++++-------------- src/core/drive/visit.js | 25 ++----------------------- src/core/native/browser_adapter.js | 1 - src/core/session.js | 25 ++----------------------- 4 files changed, 8 insertions(+), 61 deletions(-) diff --git a/src/core/drive/navigator.js b/src/core/drive/navigator.js index 8210c7471..3391a5e7b 100644 --- a/src/core/drive/navigator.js +++ b/src/core/drive/navigator.js @@ -1,6 +1,6 @@ import { getVisitAction } from "../../util" import { FormSubmission } from "./form_submission" -import { expandURL, getAnchor, getRequestURL } from "../url" +import { expandURL } from "../url" import { Visit } from "./visit" import { PageSnapshot } from "./page_snapshot" @@ -128,20 +128,10 @@ export class Navigator { delete this.currentVisit } + // Same-page links are no longer handled with a Visit. + // This method is still needed for Turbo Native adapters. locationWithActionIsSamePage(location, action) { - const anchor = getAnchor(location) - const currentAnchor = getAnchor(this.view.lastRenderedLocation) - const isRestorationToTop = action === "restore" && typeof anchor === "undefined" - - return ( - action !== "replace" && - getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) && - (isRestorationToTop || (anchor != null && anchor !== currentAnchor)) - ) - } - - visitScrolledToSamePageLocation(oldURL, newURL) { - this.delegate.visitScrolledToSamePageLocation(oldURL, newURL) + return false } // Visits diff --git a/src/core/drive/visit.js b/src/core/drive/visit.js index ec7565979..27b3a3741 100644 --- a/src/core/drive/visit.js +++ b/src/core/drive/visit.js @@ -83,7 +83,6 @@ export class Visit { this.snapshot = snapshot this.snapshotHTML = snapshotHTML this.response = response - this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action) this.isPageRefresh = this.view.isPageRefresh(this) this.visitCachedSnapshot = visitCachedSnapshot this.willRender = willRender @@ -110,10 +109,6 @@ export class Visit { return this.history.getRestorationDataForIdentifier(this.restorationIdentifier) } - get silent() { - return this.isSamePage - } - start() { if (this.state == VisitState.initialized) { this.recordTimingMetric(TimingMetric.visitStart) @@ -250,7 +245,7 @@ export class Visit { const isPreview = this.shouldIssueRequest() this.render(async () => { this.cacheSnapshot() - if (this.isSamePage || this.isPageRefresh) { + if (this.isPageRefresh) { this.adapter.visitRendered(this) } else { if (this.view.renderPromise) await this.view.renderPromise @@ -278,17 +273,6 @@ export class Visit { } } - goToSamePageAnchor() { - if (this.isSamePage) { - this.render(async () => { - this.cacheSnapshot() - this.performScroll() - this.changeHistory() - this.adapter.visitRendered(this) - }) - } - } - // Fetch request delegate prepareRequest(request) { @@ -350,9 +334,6 @@ export class Visit { } else { this.scrollToAnchor() || this.view.scrollToTop() } - if (this.isSamePage) { - this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location) - } this.scrolled = true } @@ -401,9 +382,7 @@ export class Visit { } shouldIssueRequest() { - if (this.isSamePage) { - return false - } else if (this.action == "restore") { + if (this.action == "restore") { return !this.hasCachedSnapshot() } else { return this.willRender diff --git a/src/core/native/browser_adapter.js b/src/core/native/browser_adapter.js index 6f4c73bfe..54ceda298 100644 --- a/src/core/native/browser_adapter.js +++ b/src/core/native/browser_adapter.js @@ -22,7 +22,6 @@ export class BrowserAdapter { this.location = visit.location visit.loadCachedSnapshot() visit.issueRequest() - visit.goToSamePageAnchor() } visitRequestStarted(visit) { diff --git a/src/core/session.js b/src/core/session.js index 1e1ae92ed..c88aade15 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -253,9 +253,7 @@ export class Session { this.view.markVisitDirection(visit.direction) } extendURLWithDeprecatedProperties(visit.location) - if (!visit.silent) { - this.notifyApplicationAfterVisitingLocation(visit.location, visit.action) - } + this.notifyApplicationAfterVisitingLocation(visit.location, visit.action) } visitCompleted(visit) { @@ -264,14 +262,6 @@ export class Session { this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()) } - locationWithActionIsSamePage(location, action) { - return this.navigator.locationWithActionIsSamePage(location, action) - } - - visitScrolledToSamePageLocation(oldURL, newURL) { - this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) - } - // Form submit observer delegate willSubmitForm(form, submitter) { @@ -311,9 +301,7 @@ export class Session { // Page view delegate viewWillCacheSnapshot() { - if (!this.navigator.currentVisit?.silent) { - this.notifyApplicationBeforeCachingSnapshot() - } + this.notifyApplicationBeforeCachingSnapshot() } allowsImmediateRender({ element }, options) { @@ -405,15 +393,6 @@ export class Session { }) } - notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { - dispatchEvent( - new HashChangeEvent("hashchange", { - oldURL: oldURL.toString(), - newURL: newURL.toString() - }) - ) - } - notifyApplicationAfterFrameLoad(frame) { return dispatch("turbo:frame-load", { target: frame }) }