diff --git a/src/core/drive/navigator.ts b/src/core/drive/navigator.ts index 991b11a5e..39d2fd738 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 } @@ -24,7 +25,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 9aad69841..46e3cdc91 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.ts @@ -6,7 +6,7 @@ import { clearBusyState, getAttribute, parseHTMLDocument, markAsBusy } from "../ import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission" import { Snapshot } from "../snapshot" import { ViewDelegate } from "../view" -import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" +import { getAction, expandURL, urlsAreEqual } from "../url" import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor" import { FrameView } from "./frame_view" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" @@ -326,7 +326,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 8e41aea8e..fa7b9a23e 100644 --- a/src/core/frames/frame_redirector.ts +++ b/src/core/frames/frame_redirector.ts @@ -1,15 +1,18 @@ import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor" 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, FormInterceptorDelegate { readonly element: Element + readonly session: Session readonly linkInterceptor: LinkInterceptor readonly formInterceptor: FormInterceptor - constructor(element: Element) { + constructor(element: Element, session: Session) { this.element = element + this.session = session this.linkInterceptor = new LinkInterceptor(this, element) this.formInterceptor = new FormInterceptor(this, element) } @@ -52,7 +55,7 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptor 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 55e37fa06..d9700fffb 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -5,7 +5,7 @@ import { FormSubmitObserver, FormSubmitObserverDelegate } from "../observers/for import { FrameRedirector } from "./frames/frame_redirector" import { History, HistoryDelegate } from "./drive/history" import { LinkClickObserver, LinkClickObserverDelegate } from "../observers/link_click_observer" -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" @@ -42,7 +42,7 @@ export class Session readonly scrollObserver = new ScrollObserver(this) readonly streamObserver = new StreamObserver(this) - readonly frameRedirector = new FrameRedirector(document.documentElement) + readonly frameRedirector = new FrameRedirector(document.documentElement, this) drive = true enabled = true @@ -107,6 +107,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 } @@ -149,7 +157,7 @@ export class Session willFollowLinkToLocation(link: Element, location: URL) { return ( this.elementDriveEnabled(link) && - locationIsVisitable(location, this.snapshot.rootLocation) && + this.locationIsVisitable(location, this.snapshot.rootLocation) && this.applicationAllowsFollowingLinkToLocation(link, location) ) } @@ -225,7 +233,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 2aa343276..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))$/) -} - 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/index.ts b/src/tests/functional/index.ts index 2c5c469bd..c6903f259 100644 --- a/src/tests/functional/index.ts +++ b/src/tests/functional/index.ts @@ -15,3 +15,4 @@ export * from "./rendering_tests" export * from "./scroll_restoration_tests" export * from "./stream_tests" export * from "./visit_tests" +export * from "./visitable_tests" diff --git a/src/tests/functional/visitable_tests.ts b/src/tests/functional/visitable_tests.ts new file mode 100644 index 000000000..3a1bcc536 --- /dev/null +++ b/src/tests/functional/visitable_tests.ts @@ -0,0 +1,33 @@ +import { TurboDriveTestCase } from "../helpers/turbo_drive_test_case" + +export class VisitableTests extends TurboDriveTestCase { + path = "/src/tests/fixtures/visitable.html" + + async setup() { + await this.goToLocation(this.path) + } + + async "test user-defined visitable URL"() { + await this.remote.execute(() => { + window.Turbo.session.isVisitable = url => true + }) + + this.clickSelector("#link") + await this.nextBody + this.assert.equal(await this.pathname, this.path) + this.assert.equal(await this.visitAction, "advance") + } + + async "test user-defined unvisitable URL"() { + await this.remote.execute(() => { + window.Turbo.session.isVisitable = url => false + }) + + this.clickSelector("#link") + await this.nextBody + this.assert.equal(await this.pathname, this.path) + this.assert.equal(await this.visitAction, "load") + } +} + +VisitableTests.registerSuite()