diff --git a/src/core/drive/navigator.ts b/src/core/drive/navigator.ts index 1b8f00d42..9c453229b 100644 --- a/src/core/drive/navigator.ts +++ b/src/core/drive/navigator.ts @@ -2,13 +2,14 @@ import { Action, isAction } from "../types" import { FetchMethod } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { FormSubmission } from "./form_submission" -import { expandURL, getAnchor, getRequestURL, Locatable, locationIsVisitable } from "../url" +import { expandURL, getAnchor, getRequestURL, Locatable } from "../url" import { getAttribute } from "../../util" import { Visit, VisitDelegate, VisitOptions } from "./visit" import { PageSnapshot } from "./page_snapshot" export type NavigatorDelegate = VisitDelegate & { allowsVisitingLocationWithAction(location: URL, action?: Action): boolean + locationIsVisitable(location: URL, rootLocation: URL): boolean visitProposedToLocation(location: URL, options: Partial): void notifyApplicationAfterVisitingSamePageLocation(oldURL: URL, newURL: URL): void } @@ -25,7 +26,7 @@ export class Navigator { proposeVisit(location: URL, options: Partial = {}) { if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { - if (locationIsVisitable(location, this.view.snapshot.rootLocation)) { + if (this.delegate.locationIsVisitable(location, this.view.snapshot.rootLocation)) { this.delegate.visitProposedToLocation(location, options) } else { window.location.href = location.toString() diff --git a/src/core/frames/frame_controller.ts b/src/core/frames/frame_controller.ts index f09d7b86d..72c04be79 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.ts @@ -11,7 +11,7 @@ import { clearBusyState, dispatch, getAttribute, parseHTMLDocument, markAsBusy } import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission" import { Snapshot } from "../snapshot" import { ViewDelegate, ViewRenderOptions } from "../view" -import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" +import { getAction, expandURL, urlsAreEqual } from "../url" import { FormSubmitObserver, FormSubmitObserverDelegate } from "../../observers/form_submit_observer" import { FrameView } from "./frame_view" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" @@ -382,7 +382,7 @@ export class FrameController private formActionIsVisitable(form: HTMLFormElement, submitter?: HTMLElement) { const action = getAction(form, submitter) - return locationIsVisitable(expandURL(action), this.rootLocation) + return session.locationIsVisitable(expandURL(action), this.rootLocation) } private shouldInterceptNavigation(element: Element, submitter?: HTMLElement) { diff --git a/src/core/frames/frame_redirector.ts b/src/core/frames/frame_redirector.ts index 93c9bbc5f..7b6f449c4 100644 --- a/src/core/frames/frame_redirector.ts +++ b/src/core/frames/frame_redirector.ts @@ -1,15 +1,18 @@ import { FormSubmitObserver, FormSubmitObserverDelegate } from "../../observers/form_submit_observer" import { FrameElement } from "../../elements/frame_element" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" -import { expandURL, getAction, locationIsVisitable } from "../url" +import { Session } from "../session" +import { expandURL, getAction } from "../url" export class FrameRedirector implements LinkInterceptorDelegate, FormSubmitObserverDelegate { readonly element: Element + readonly session: Session readonly linkInterceptor: LinkInterceptor readonly formSubmitObserver: FormSubmitObserver - constructor(element: Element) { + constructor(element: Element, session: Session) { this.element = element + this.session = session this.linkInterceptor = new LinkInterceptor(this, element) this.formSubmitObserver = new FormSubmitObserver(this, element) } @@ -55,7 +58,7 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormSubmitObser const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`) const rootLocation = expandURL(meta?.content ?? "/") - return this.shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation) + return this.shouldRedirect(form, submitter) && this.session.locationIsVisitable(action, rootLocation) } private shouldRedirect(element: Element, submitter?: HTMLElement) { diff --git a/src/core/session.ts b/src/core/session.ts index a6d53540c..355142107 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -6,7 +6,7 @@ import { FrameRedirector } from "./frames/frame_redirector" import { History, HistoryDelegate } from "./drive/history" import { LinkClickObserver, LinkClickObserverDelegate } from "../observers/link_click_observer" import { FormLinkInterceptor, FormLinkInterceptorDelegate } from "../observers/form_link_interceptor" -import { getAction, expandURL, locationIsVisitable, Locatable } from "./url" +import { getAction, getExtension, expandURL, isPrefixedBy, Locatable } from "./url" import { Navigator, NavigatorDelegate } from "./drive/navigator" import { PageObserver, PageObserverDelegate } from "../observers/page_observer" import { ScrollObserver } from "../observers/scroll_observer" @@ -58,7 +58,7 @@ export class Session readonly scrollObserver = new ScrollObserver(this) readonly streamObserver = new StreamObserver(this) readonly formLinkInterceptor = new FormLinkInterceptor(this, document.documentElement) - readonly frameRedirector = new FrameRedirector(document.documentElement) + readonly frameRedirector = new FrameRedirector(document.documentElement, this) drive = true enabled = true @@ -126,6 +126,14 @@ export class Session this.view.clearSnapshotCache() } + isVisitable(url: URL) { + return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) + } + + locationIsVisitable(location: URL, rootLocation: URL) { + return isPrefixedBy(location, rootLocation) && this.isVisitable(location) + } + setProgressBarDelay(delay: number) { this.progressBarDelay = delay } @@ -176,7 +184,7 @@ export class Session willFollowLinkToLocation(link: Element, location: URL, event: MouseEvent) { return ( this.elementDriveEnabled(link) && - locationIsVisitable(location, this.snapshot.rootLocation) && + this.locationIsVisitable(location, this.snapshot.rootLocation) && this.applicationAllowsFollowingLinkToLocation(link, location, event) ) } @@ -224,7 +232,7 @@ export class Session return ( this.elementDriveEnabled(form) && (!submitter || this.formElementDriveEnabled(submitter)) && - locationIsVisitable(expandURL(action), this.snapshot.rootLocation) + this.locationIsVisitable(expandURL(action), this.snapshot.rootLocation) ) } diff --git a/src/core/url.ts b/src/core/url.ts index 0e45d8f2b..5b68ecad4 100644 --- a/src/core/url.ts +++ b/src/core/url.ts @@ -24,19 +24,11 @@ export function getExtension(url: URL) { return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" } -export function isHTML(url: URL) { - return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) -} - export function isPrefixedBy(baseURL: URL, url: URL) { const prefix = getPrefix(url) return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix) } -export function locationIsVisitable(location: URL, rootLocation: URL) { - return isPrefixedBy(location, rootLocation) && isHTML(location) -} - export function getRequestURL(url: URL) { const anchor = getAnchor(url) return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href diff --git a/src/tests/fixtures/visitable.html b/src/tests/fixtures/visitable.html new file mode 100644 index 000000000..659a49e2a --- /dev/null +++ b/src/tests/fixtures/visitable.html @@ -0,0 +1,16 @@ + + + + + Visitable + + + + +

Visitable

+ +
+ link +
+ + diff --git a/src/tests/functional/visitable_tests.ts b/src/tests/functional/visitable_tests.ts new file mode 100644 index 000000000..0967185d3 --- /dev/null +++ b/src/tests/functional/visitable_tests.ts @@ -0,0 +1,31 @@ +import { test } from "@playwright/test" +import { assert } from "chai" +import { nextBody, pathname, visitAction } from "../helpers/page" + +const path = "/src/tests/fixtures/visitable.html" + +test.beforeEach(async ({ page }) => { + await page.goto(path) +}) + +test("test user-defined visitable URL", async ({ page }) => { + await page.evaluate(() => { + window.Turbo.session.isVisitable = (_url) => true + }) + + page.click("#link") + await nextBody(page) + assert.equal(pathname(page.url()), path) + assert.equal(await visitAction(page), "advance") +}) + +test("test user-defined unvisitable URL", async ({ page }) => { + await page.evaluate(() => { + window.Turbo.session.isVisitable = (_url) => false + }) + + page.click("#link") + await nextBody(page) + assert.equal(pathname(page.url()), path) + assert.equal(await visitAction(page), "load") +})