diff --git a/src/core/drive/form_submission.ts b/src/core/drive/form_submission.ts index 9e4087dc9..685938d27 100644 --- a/src/core/drive/form_submission.ts +++ b/src/core/drive/form_submission.ts @@ -90,7 +90,7 @@ export class FormSubmission { requestStarted(request: FetchRequest) { this.state = FormSubmissionState.waiting - dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } }) + dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this }, bubbles: true }) this.delegate.formSubmissionStarted(this) } @@ -121,7 +121,7 @@ export class FormSubmission { requestFinished(request: FetchRequest) { this.state = FormSubmissionState.stopped - dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }}) + dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }, bubbles: true }) this.delegate.formSubmissionFinished(this) } diff --git a/src/core/frames/frame_controller.ts b/src/core/frames/frame_controller.ts index f0ca60cb0..3d94fa4a8 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.ts @@ -5,7 +5,8 @@ import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission import { FrameElement } from "../../elements/frame_element" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" import { Locatable, Location } from "../location" -import { nextAnimationFrame } from "../../util" +import { dispatch, nextAnimationFrame } from "../../util" +import { TimingMetric, TimingMetrics } from "../drive/visit" export class FrameController implements FetchRequestDelegate, FormInterceptorDelegate, FormSubmissionDelegate, LinkInterceptorDelegate { readonly element: FrameElement @@ -13,6 +14,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel readonly formInterceptor: FormInterceptor formSubmission?: FormSubmission private resolveVisitPromise = () => {} + timingMetrics: TimingMetrics = {} constructor(element: FrameElement) { this.element = element @@ -35,7 +37,13 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel } linkClickIntercepted(element: Element, url: string) { - this.navigateFrame(element, url) + const frame = this.findFrameElement(element) + const location = Location.wrap(url) + const event = dispatch("turbo:before-visit", { target: frame, bubbles: false, detail: { url: location.absoluteURL } }) + + if (!event.defaultPrevented) { + frame.src = url + } } shouldInterceptFormSubmission(element: HTMLFormElement) { @@ -49,7 +57,8 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel this.formSubmission = new FormSubmission(this, element, submitter) if (this.formSubmission.fetchRequest.isIdempotent) { - this.navigateFrame(element, this.formSubmission.fetchRequest.url) + const frame = this.findFrameElement(element) + frame.src = this.formSubmission.fetchRequest.url } else { this.formSubmission.start() } @@ -58,13 +67,19 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel async visit(url: Locatable) { const location = Location.wrap(url) const request = new FetchRequest(this, FetchMethod.get, location) + this.clearTimingMetrics() + this.recordTimingMetric(TimingMetric.visitStart) return new Promise(resolve => { this.resolveVisitPromise = () => { this.resolveVisitPromise = () => {} resolve() } + dispatch("turbo:visit", { target: this.element, bubbles: false, detail: { url: location.absoluteURL } }) request.perform() + }).then(() => { + this.recordTimingMetric(TimingMetric.visitEnd) + dispatch("turbo:load", { target: this.element, bubbles: false, detail: { url: location.absoluteURL, timing: this.timingMetrics } }) }) } @@ -74,6 +89,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel requestStarted(request: FetchRequest) { this.element.setAttribute("busy", "") + this.recordTimingMetric(TimingMetric.requestStart) } requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse) { @@ -97,6 +113,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel requestFinished(request: FetchRequest) { this.element.removeAttribute("busy") + this.recordTimingMetric(TimingMetric.requestEnd) } formSubmissionStarted(formSubmission: FormSubmission) { @@ -120,11 +137,6 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel } - private navigateFrame(element: Element, url: string) { - const frame = this.findFrameElement(element) - frame.src = url - } - private findFrameElement(element: Element) { const id = element.getAttribute("data-turbo-frame") return getFrameElementById(id) ?? this.element @@ -133,6 +145,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel private async loadResponse(response: FetchResponse): Promise { const fragment = fragmentFromHTML(await response.responseHTML) const element = await this.extractForeignFrameElement(fragment) + dispatch("turbo:before-render", { target: this.element, bubbles: false, detail: { newBody: element } }) if (element) { await nextAnimationFrame() @@ -143,6 +156,14 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel } } + private clearTimingMetrics() { + this.timingMetrics = {} + } + + private recordTimingMetric(metric: TimingMetric) { + this.timingMetrics[metric] = new Date().getTime() + } + private async extractForeignFrameElement(container: ParentNode): Promise { let element const id = CSS.escape(this.id) @@ -167,6 +188,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel sourceRange.selectNodeContents(frameElement) this.element.appendChild(sourceRange.extractContents()) } + dispatch("turbo:render", { target: this.element, bubbles: false }) } private focusFirstAutofocusableElement(): boolean { diff --git a/src/core/frames/frame_redirector.ts b/src/core/frames/frame_redirector.ts index 3f898ac69..4633cbc02 100644 --- a/src/core/frames/frame_redirector.ts +++ b/src/core/frames/frame_redirector.ts @@ -1,6 +1,8 @@ import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor" import { FrameElement } from "../../elements/frame_element" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" +import { Location } from "../location" +import { dispatch } from "../../util" export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptorDelegate { readonly element: Element @@ -29,8 +31,14 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptor linkClickIntercepted(element: Element, url: string) { const frame = this.findFrameElement(element) + const location = Location.wrap(url) + if (frame) { - frame.src = url + const event = dispatch("turbo:before-visit", { target: frame, bubbles: false, detail: { url: location.absoluteURL } }) + + if (!event.defaultPrevented) { + frame.src = url + } } } diff --git a/src/core/session.ts b/src/core/session.ts index e727dcc83..210c7ef66 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -13,9 +13,7 @@ import { StreamObserver } from "../observers/stream_observer" import { Action, Position, StreamSource, isAction } from "./types" import { dispatch } from "../util" import { View } from "./drive/view" -import { Visit, VisitOptions } from "./drive/visit" - -export type TimingData = {} +import { TimingMetrics, Visit, VisitOptions } from "./drive/visit" export class Session implements NavigatorDelegate { readonly navigator = new Navigator(this) @@ -212,31 +210,31 @@ export class Session implements NavigatorDelegate { } notifyApplicationAfterClickingLinkToLocation(link: Element, location: Location) { - return dispatch("turbo:click", { target: link, detail: { url: location.absoluteURL }, cancelable: true }) + return dispatch("turbo:click", { target: link, detail: { url: location.absoluteURL }, cancelable: true, bubbles: true }) } notifyApplicationBeforeVisitingLocation(location: Location) { - return dispatch("turbo:before-visit", { detail: { url: location.absoluteURL }, cancelable: true }) + return dispatch("turbo:before-visit", { detail: { url: location.absoluteURL }, cancelable: true, bubbles: true }) } notifyApplicationAfterVisitingLocation(location: Location) { - return dispatch("turbo:visit", { detail: { url: location.absoluteURL } }) + return dispatch("turbo:visit", { detail: { url: location.absoluteURL }, bubbles: true }) } notifyApplicationBeforeCachingSnapshot() { - return dispatch("turbo:before-cache") + return dispatch("turbo:before-cache", { bubbles: true }) } notifyApplicationBeforeRender(newBody: HTMLBodyElement) { - return dispatch("turbo:before-render", { detail: { newBody }}) + return dispatch("turbo:before-render", { detail: { newBody }, bubbles: true }) } notifyApplicationAfterRender() { - return dispatch("turbo:render") + return dispatch("turbo:render", { bubbles: true }) } - notifyApplicationAfterPageLoad(timing: TimingData = {}) { - return dispatch("turbo:load", { detail: { url: this.location.absoluteURL, timing }}) + notifyApplicationAfterPageLoad(timing: TimingMetrics = {}) { + return dispatch("turbo:load", { detail: { url: this.location.absoluteURL, timing }, bubbles: true }) } // Private diff --git a/src/http/fetch_request.ts b/src/http/fetch_request.ts index 0dcc206f2..4cad49dfc 100644 --- a/src/http/fetch_request.ts +++ b/src/http/fetch_request.ts @@ -81,7 +81,7 @@ export class FetchRequest { async perform(): Promise { const { fetchOptions } = this - dispatch("turbo:before-fetch-request", { detail: { fetchOptions } }) + dispatch("turbo:before-fetch-request", { bubbles: true, detail: { fetchOptions } }) try { this.delegate.requestStarted(this) const response = await fetch(this.url, fetchOptions) @@ -96,7 +96,7 @@ export class FetchRequest { async receive(response: Response): Promise { const fetchResponse = new FetchResponse(response) - const event = dispatch("turbo:before-fetch-response", { cancelable: true, detail: { fetchResponse } }) + const event = dispatch("turbo:before-fetch-response", { cancelable: true, bubbles: true, detail: { fetchResponse } }) if (event.defaultPrevented) { this.delegate.requestPreventedHandlingResponse(this, fetchResponse) } else if (fetchResponse.succeeded) { diff --git a/src/tests/fixtures/frame_navigation.html b/src/tests/fixtures/frame_navigation.html new file mode 100644 index 000000000..dde8b1b5c --- /dev/null +++ b/src/tests/fixtures/frame_navigation.html @@ -0,0 +1,20 @@ + + + + + Turbo + + + +
+ Outside Frame + + +

Frame Navigation

+ + Inside Frame + Top +
+
+ + diff --git a/src/tests/functional/frame_navigation_tests.ts b/src/tests/functional/frame_navigation_tests.ts new file mode 100644 index 000000000..d3cb89e51 --- /dev/null +++ b/src/tests/functional/frame_navigation_tests.ts @@ -0,0 +1,103 @@ +import { TurboDriveTestCase } from "../helpers/turbo_drive_test_case" + +export class FrameNavigationTests extends TurboDriveTestCase { + async setup() { + await this.goToLocation("/src/tests/fixtures/frame_navigation.html") + } + + async "test frame navigation with descendant link"() { + this.trackFrameEvents() + await this.clickSelector("#inside") + await this.nextBeat + + const dispatchedEvents = await this.readDispatchedFrameEvents() + const [ + [ beforeVisit, beforeVisitTarget, { url } ], + [ visit, visitTarget ], + [ beforeRender, beforeRenderTarget, { newBody } ], + [ render, renderTarget ], + [ load, loadTarget, { timing } ], + ] = dispatchedEvents + + this.assert.equal(beforeVisit, "turbo:before-visit") + this.assert.equal(beforeVisitTarget, "frame") + this.assert.ok(url.includes("/src/tests/fixtures/frame_navigation.html")) + + this.assert.equal(visit, "turbo:visit") + this.assert.equal(visitTarget, "frame") + + this.assert.equal(beforeRender, "turbo:before-render") + this.assert.equal(beforeRenderTarget, "frame") + this.assert.ok(newBody) + + this.assert.equal(render, "turbo:render") + this.assert.equal(renderTarget, "frame") + + this.assert.equal(load, "turbo:load") + this.assert.equal(loadTarget, "frame") + this.assert.ok(Object.keys(timing).length) + } + + async "test frame navigation with exterior link"() { + this.trackFrameEvents() + await this.clickSelector("#outside") + await this.nextBeat + + const dispatchedEvents = await this.readDispatchedFrameEvents() + const [ + [ beforeVisit, beforeVisitTarget, { url } ], + [ visit, visitTarget ], + [ beforeRender, beforeRenderTarget, { newBody } ], + [ render, renderTarget ], + [ load, loadTarget, { timing } ], + ] = dispatchedEvents + + this.assert.equal(beforeVisit, "turbo:before-visit") + this.assert.equal(beforeVisitTarget, "frame") + this.assert.ok(url.includes("/src/tests/fixtures/frame_navigation.html")) + + this.assert.equal(visit, "turbo:visit") + this.assert.equal(visitTarget, "frame") + + this.assert.equal(beforeRender, "turbo:before-render") + this.assert.equal(beforeRenderTarget, "frame") + this.assert.ok(newBody) + + this.assert.equal(render, "turbo:render") + this.assert.equal(renderTarget, "frame") + + this.assert.equal(load, "turbo:load") + this.assert.equal(loadTarget, "frame") + this.assert.ok(Object.keys(timing).length) + } + + async trackFrameEvents() { + this.remote.execute(() => { + const eventNames = "turbo:before-visit turbo:visit turbo:before-render turbo:render turbo:load".split(/\s+/) + document.head.insertAdjacentHTML("beforeend", ``) + const frame = document.getElementById("frame") + + if (frame) { + eventNames.forEach(eventName => frame.addEventListener(eventName, (event) => { + const meta = document.getElementById("events") + + if (meta instanceof HTMLMetaElement && event instanceof CustomEvent && event.target instanceof HTMLElement) { + const dispatchedEvents = JSON.parse(meta.content) + const detail = event.detail || {} + dispatchedEvents.push([ event.type, event.target.id, { ...detail, newBody: !!detail.newBody } ]) + meta.content = JSON.stringify(dispatchedEvents) + } + })) + } + }) + } + + async readDispatchedFrameEvents() { + const meta = await this.querySelector("meta[id=events]") + const content = await meta.getAttribute("content") + + return JSON.parse(content || "[]") + } +} + +FrameNavigationTests.registerSuite() diff --git a/src/tests/functional/index.ts b/src/tests/functional/index.ts index e4a0ac5a8..d2e9eedf1 100644 --- a/src/tests/functional/index.ts +++ b/src/tests/functional/index.ts @@ -1,5 +1,6 @@ export * from "./async_script_tests" export * from "./form_submission_tests" +export * from "./frame_navigation_tests" export * from "./navigation_tests" export * from "./rendering_tests" export * from "./stream_tests" diff --git a/src/util.ts b/src/util.ts index 2dbbecdef..6ceaa5287 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,7 +1,7 @@ -export type DispatchOptions = { target: EventTarget, cancelable: boolean, detail: any } +export type DispatchOptions = { target: EventTarget, cancelable: boolean, bubbles: boolean, detail: any } -export function dispatch(eventName: string, { target, cancelable, detail }: Partial = {}) { - const event = new CustomEvent(eventName, { cancelable, bubbles: true, detail }) +export function dispatch(eventName: string, { target, cancelable, bubbles, detail }: Partial = {}) { + const event = new CustomEvent(eventName, { cancelable, bubbles, detail }) void (target || document.documentElement).dispatchEvent(event) return event }