diff --git a/src/core/drive/history.js b/src/core/drive/history.js index 215f56eca..45015b0cc 100644 --- a/src/core/drive/history.js +++ b/src/core/drive/history.js @@ -6,6 +6,7 @@ export class History { restorationData = {} started = false pageLoaded = false + currentIndex = 0 constructor(delegate) { this.delegate = delegate @@ -15,6 +16,7 @@ export class History { 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)) } @@ -37,7 +39,9 @@ export class History { } update(method, location, restorationIdentifier = uuid()) { - const state = { turbo: { restorationIdentifier } } + if (method === history.pushState) ++this.currentIndex + + const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } } method.call(history, state, "", location.href) this.location = location this.restorationIdentifier = restorationIdentifier @@ -81,9 +85,11 @@ export class History { const { turbo } = event.state || {} if (turbo) { this.location = new URL(window.location.href) - const { restorationIdentifier } = turbo + const { restorationIdentifier, restorationIndex } = turbo this.restorationIdentifier = restorationIdentifier - this.delegate.historyPoppedToLocationWithRestorationIdentifier(this.location, restorationIdentifier) + const direction = restorationIndex > this.currentIndex ? "forward" : "back" + this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction) + this.currentIndex = restorationIndex } } } diff --git a/src/core/drive/visit.js b/src/core/drive/visit.js index 7fc494c19..a29c96692 100644 --- a/src/core/drive/visit.js +++ b/src/core/drive/visit.js @@ -36,6 +36,12 @@ export const SystemStatusCode = { contentTypeMismatch: -2 } +export const Direction = { + advance: "forward", + restore: "back", + replace: "none" +} + export class Visit { identifier = uuid() // Required by turbo-ios timingMetrics = {} @@ -65,7 +71,8 @@ export class Visit { willRender, updateHistory, shouldCacheSnapshot, - acceptsStreamResponse + acceptsStreamResponse, + direction } = { ...defaultOptions, ...options @@ -83,6 +90,7 @@ export class Visit { this.scrolled = !willRender this.shouldCacheSnapshot = shouldCacheSnapshot this.acceptsStreamResponse = acceptsStreamResponse + this.direction = direction || Direction[action] } get adapter() { diff --git a/src/core/session.js b/src/core/session.js index 44edc7856..a94bf1c91 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -125,11 +125,12 @@ export class Session { // History delegate - historyPoppedToLocationWithRestorationIdentifier(location, restorationIdentifier) { + historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) { if (this.enabled) { this.navigator.startVisit(location, restorationIdentifier, { action: "restore", - historyChanged: true + historyChanged: true, + direction }) } else { this.adapter.pageInvalidated({ @@ -185,6 +186,7 @@ export class Session { visitStarted(visit) { if (!visit.acceptsStreamResponse) { markAsBusy(document.documentElement) + this.view.markVisitDirection(visit.direction) } extendURLWithDeprecatedProperties(visit.location) if (!visit.silent) { @@ -193,6 +195,7 @@ export class Session { } visitCompleted(visit) { + this.view.unmarkVisitDirection() clearBusyState(document.documentElement) this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()) } diff --git a/src/core/view.js b/src/core/view.js index ca81e8bdb..5c62259f1 100644 --- a/src/core/view.js +++ b/src/core/view.js @@ -99,6 +99,14 @@ export class View { } } + markVisitDirection(direction) { + this.element.setAttribute("data-turbo-visit-direction", direction) + } + + unmarkVisitDirection() { + this.element.removeAttribute("data-turbo-visit-direction") + } + async renderSnapshot(renderer) { await renderer.render() } diff --git a/src/tests/fixtures/visit.html b/src/tests/fixtures/visit.html index f4cef2457..19e40e5bb 100644 --- a/src/tests/fixtures/visit.html +++ b/src/tests/fixtures/visit.html @@ -13,6 +13,7 @@

Visit

Same-origin link

+

Same-origin replace link

Same-origin link with ?key=value

Sample response

Same page link

diff --git a/src/tests/functional/visit_tests.js b/src/tests/functional/visit_tests.js index 102cc8a2b..31a11141a 100644 --- a/src/tests/functional/visit_tests.js +++ b/src/tests/functional/visit_tests.js @@ -6,13 +6,16 @@ import { getSearchParam, isScrolledToSelector, isScrolledToTop, + nextAttributeMutationNamed, nextBeat, nextEventNamed, noNextAttributeMutationNamed, pathname, readEventLogs, + resetMutationLogs, scrollToSelector, visitAction, + waitUntilNoSelector, willChangeBody } from "../helpers/page" @@ -220,6 +223,55 @@ test("test Visit with network error", async ({ page }) => { await nextEventNamed(page, "turbo:fetch-request-error") }) +test("Visit direction data attribute when clicking a link", async ({ page }) => { + page.click("#same-origin-link") + await assertVisitDirectionAttribute(page, "forward") +}) + +test("Visit direction data attribute when navigating back", async ({ page }) => { + await page.click("#same-origin-link") + await nextEventNamed(page, "turbo:load") + + await resetMutationLogs(page) + + page.goBack() + + await assertVisitDirectionAttribute(page, "back") +}) + +test("Visit direction attribute when navigating forward", async ({ page }) => { + await page.click("#same-origin-link") + await nextEventNamed(page, "turbo:load") + await page.goBack() + await nextEventNamed(page, "turbo:load") + + page.goForward() + + await assertVisitDirectionAttribute(page, "forward") +}) + +test("Visit direction attribute on a replace visit", async ({ page }) => { + page.click("#same-origin-replace-link") + + await assertVisitDirectionAttribute(page, "none") +}) + +test("Turbo history state after a reload", async ({ page }) => { + await page.click("#same-origin-link") + await nextEventNamed(page, "turbo:load") + await page.reload() + assert.equal( + await page.evaluate(() => window.history.state.turbo.restorationIndex), + 1, + "restorationIndex is persisted between reloads" + ) +}) + async function visitLocation(page, location) { return page.evaluate((location) => window.Turbo.visit(location), location) } + +async function assertVisitDirectionAttribute(page, direction) { + assert.equal(await nextAttributeMutationNamed(page, "html", "data-turbo-visit-direction"), direction) + await waitUntilNoSelector(page, "[data-turbo-visit-direction]") +} diff --git a/src/tests/helpers/page.js b/src/tests/helpers/page.js index 580327d25..5d203681b 100644 --- a/src/tests/helpers/page.js +++ b/src/tests/helpers/page.js @@ -162,6 +162,12 @@ export function propertyForSelector(page, selector, propertyName) { return page.locator(selector).evaluate((element, propertyName) => element[propertyName], propertyName) } +export function resetMutationLogs(page) { + return page.evaluate(() => { + window.mutationLogs = [] + }) +} + async function readArray(page, identifier, length) { return page.evaluate( ({ identifier, length }) => {