From 51c406d87761650958edd46ede45c1ef91699c4c Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Mon, 25 Oct 2021 19:22:25 -0400 Subject: [PATCH] Extract `FrameVisit` to drive `FrameController` The problem --- Programmatically driving a `` element when its `[src]` attribute changes is a suitable end-user experience in consumer applications. It's a fitting black-box interface for the outside world: change the value of the attribute and let Turbo handle the rest. However, internally, it's a lossy abstraction. For example, when the `FrameRedirector` class listens for page-wide `click` and `submit` events, it determines if their targets are meant to drive a `` element by: 1. finding an element that matches a clicked `` element's `[data-turbo-frame]` attribute 2. finding an element that matches a submitted `
` element's `[data-turbo-frame]` attribute 3. finding an element that matches a submitted `` element's _submitter's_ `[data-turbo-frame]` attribute 4. finding the closest `` ancestor to the `` or `` Once it finds the matching frame element, it disposes of all that additional context and navigates the `` by updating its `[src]` attribute. This makes it impossible to control various aspects of the frame navigation (like its "rendering" explored in [hotwired/turbo#146][]) outside of its destination URL. Similarly, since a `` and submitter pairing have an impact on which `` is navigated, the `FrameController` implementation passes around a `HTMLFormElement` and `HTMLSubmitter?` data clump and constantly re-fetches a matching `` instance. Outside of frames, page-wide navigation is driven by a `Visit` instance that manages the HTTP life cycle and delegates along the way to a `Visit` delegate. It also pairs calls to visit with an option object to capture additional context. The proposal --- This commit introduces the `FrameVisit` class. It serves as an encapsulation of the `FetchRequest` and `FormSubmission` lifecycle events involved in navigating a frame. It's implementation draws inspiration from the `Visit` class's delegate and option structures. Since the `FrameVisit` knows how to unify both `FetchRequest` and `FormSubmission` hooks, the resulting callbacks fired from within the `FrameController` are flat and consistent. Extra benefits --- The biggest benefit is the introduction of a DRY abstraction to manage the behind the scenes HTTP calls necessary to drive a ``. With the introduction of the `FrameVisit` concept, we can also declare a `visit()` and `submit()` method for `FrameElement` delegate implementations in the place of other implementation-specific methods like `loadResponse()` and `formSubmissionIntercepted()`. In addition, these changes have the potential to close [hotwired/turbo#326][], since we can consistently invoke `loadResponse()` across ``-click-initiated and ``-submission-initiated visits. To ensure that's the case, this commit adds test coverage for navigating a `` by making a `GET` request to an endpoint that responds with a `500` status. [hotwired/turbo#146]: https://github.com/hotwired/turbo/pull/146 [hotwired/turbo#326]: https://github.com/hotwired/turbo/issues/326 --- src/core/frames/frame_controller.js | 221 ++++++------------ src/core/frames/frame_visit.js | 152 ++++++++++++ src/core/session.js | 8 +- src/tests/fixtures/tabs/three.html | 4 +- src/tests/fixtures/tabs/two.html | 2 + .../functional/frame_navigation_tests.js | 107 ++++++++- src/tests/functional/frame_tests.js | 20 +- 7 files changed, 353 insertions(+), 161 deletions(-) create mode 100644 src/core/frames/frame_visit.js diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js index 07764fc86..a7183006a 100644 --- a/src/core/frames/frame_controller.js +++ b/src/core/frames/frame_controller.js @@ -1,18 +1,12 @@ import { FrameElement, FrameLoadingStyle } from "../../elements/frame_element" -import { FetchMethod, FetchRequest } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { AppearanceObserver } from "../../observers/appearance_observer" import { - clearBusyState, dispatch, getAttribute, - parseHTMLDocument, - markAsBusy, uuid, - getHistoryMethodForAction, - getVisitAction + getHistoryMethodForAction } from "../../util" -import { FormSubmission } from "../drive/form_submission" import { Snapshot } from "../snapshot" import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" import { FormSubmitObserver } from "../../observers/form_submit_observer" @@ -21,18 +15,15 @@ import { LinkInterceptor } from "./link_interceptor" import { FormLinkClickObserver } from "../../observers/form_link_click_observer" import { FrameRenderer } from "./frame_renderer" import { session } from "../index" -import { StreamMessage } from "../streams/stream_message" import { PageSnapshot } from "../drive/page_snapshot" import { TurboFrameMissingError } from "../errors" +import { FrameVisit } from "./frame_visit" export class FrameController { - fetchResponseLoaded = (_fetchResponse) => Promise.resolve() - #currentFetchRequest = null - #resolveVisitPromise = () => {} #connected = false #hasBeenLoaded = false #ignoredAttributes = new Set() - action = null + #frameVisit = null constructor(element) { this.element = element @@ -70,6 +61,11 @@ export class FrameController { } } + visit(options) { + const frameVisit = new FrameVisit(this, this.element, options) + return frameVisit.start() + } + disabledChanged() { if (this.loadingStyle == FrameLoadingStyle.eager) { this.#loadSourceURL() @@ -107,39 +103,30 @@ export class FrameController { async #loadSourceURL() { if (this.enabled && this.isActive && !this.complete && this.sourceURL) { - this.element.loaded = this.#visit(expandURL(this.sourceURL)) - this.appearanceObserver.stop() - await this.element.loaded - this.#hasBeenLoaded = true + await this.visit({ url: this.sourceURL }) } } - async loadResponse(fetchResponse) { + async loadResponse(fetchResponse, frameVisit) { if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url } - try { - const html = await fetchResponse.responseHTML - if (html) { - const document = parseHTMLDocument(html) - const pageSnapshot = PageSnapshot.fromDocument(document) - - if (pageSnapshot.isVisitable) { - await this.#loadFrameResponse(fetchResponse, document) - } else { - await this.#handleUnvisitableFrameResponse(fetchResponse) - } + const html = await fetchResponse.responseHTML + if (html) { + const pageSnapshot = PageSnapshot.fromHTMLString(html) + + if (pageSnapshot.isVisitable) { + await this.#loadFrameResponse(fetchResponse, pageSnapshot, frameVisit) + } else { + await this.#handleUnvisitableFrameResponse(fetchResponse) } - } finally { - this.fetchResponseLoaded = () => Promise.resolve() } } // Appearance observer delegate elementAppearedInViewport(element) { - this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element)) this.#loadSourceURL() } @@ -171,81 +158,48 @@ export class FrameController { } formSubmitted(element, submitter) { - if (this.formSubmission) { - this.formSubmission.stop() - } - - this.formSubmission = new FormSubmission(this, element, submitter) - const { fetchRequest } = this.formSubmission - this.prepareRequest(fetchRequest) - this.formSubmission.start() - } - - // Fetch request delegate - - prepareRequest(request) { - request.headers["Turbo-Frame"] = this.id - - if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { - request.acceptResponseType(StreamMessage.contentType) - } - } - - requestStarted(_request) { - markAsBusy(this.element) - } - - requestPreventedHandlingResponse(_request, _response) { - this.#resolveVisitPromise() - } - - async requestSucceededWithResponse(request, response) { - await this.loadResponse(response) - this.#resolveVisitPromise() - } - - async requestFailedWithResponse(request, response) { - await this.loadResponse(response) - this.#resolveVisitPromise() + const frame = this.#findFrameElement(element, submitter) + frame.delegate.visit(FrameVisit.optionsForSubmit(element, submitter)) } - requestErrored(request, error) { - console.error(error) - this.#resolveVisitPromise() - } + // Frame visit delegate - requestFinished(_request) { - clearBusyState(this.element) + shouldVisitFrame(_frameVisit) { + return this.enabled && this.isActive } - // Form submission delegate - - formSubmissionStarted({ formElement }) { - markAsBusy(formElement, this.#findFrameElement(formElement)) + frameVisitStarted(frameVisit) { + this.#ignoringChangesToAttribute("complete", () => { + this.#frameVisit?.stop() + this.#frameVisit = frameVisit + this.element.removeAttribute("complete") + }) } - formSubmissionSucceededWithResponse(formSubmission, response) { - const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter) + async frameVisitSucceededWithResponse(frameVisit, fetchResponse) { + await this.loadResponse(fetchResponse, frameVisit) - frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame)) - frame.delegate.loadResponse(response) - - if (!formSubmission.isSafe) { + if (!frameVisit.isSafe) { session.clearCache() } } - formSubmissionFailedWithResponse(formSubmission, fetchResponse) { - this.element.delegate.loadResponse(fetchResponse) + async frameVisitFailedWithResponse(frameVisit, fetchResponse) { + await this.loadResponse(fetchResponse, frameVisit) + session.clearCache() } - formSubmissionErrored(formSubmission, error) { + frameVisitErrored(_frameVisit, fetchRequest, error) { console.error(error) + dispatch("turbo:fetch-request-error", { + target: this.element, + detail: { request: fetchRequest, error } + }) } - formSubmissionFinished({ formElement }) { - clearBusyState(formElement, this.#findFrameElement(formElement)) + frameVisitCompleted(_frameVisit) { + this.hasBeenLoaded = true } // View delegate @@ -294,83 +248,54 @@ export class FrameController { // Private - async #loadFrameResponse(fetchResponse, document) { - const newFrameElement = await this.extractForeignFrameElement(document.body) + async #loadFrameResponse(fetchResponse, pageSnapshot, frameVisit) { + const newFrameElement = await this.extractForeignFrameElement(pageSnapshot.element) if (newFrameElement) { const snapshot = new Snapshot(newFrameElement) const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false) if (this.view.renderPromise) await this.view.renderPromise - this.changeHistory() + this.changeHistory(frameVisit.action) await this.view.render(renderer) this.complete = true session.frameRendered(fetchResponse, this.element) session.frameLoaded(this.element) - await this.fetchResponseLoaded(fetchResponse) + await this.#proposeVisitIfNavigatedWithAction(frameVisit, fetchResponse) } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) { this.#handleFrameMissingFromResponse(fetchResponse) } } - async #visit(url) { - const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element) - - this.#currentFetchRequest?.cancel() - this.#currentFetchRequest = request - - return new Promise((resolve) => { - this.#resolveVisitPromise = () => { - this.#resolveVisitPromise = () => {} - this.#currentFetchRequest = null - resolve() - } - request.perform() - }) - } - - #navigateFrame(element, url, submitter) { - const frame = this.#findFrameElement(element, submitter) - - frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter, element, frame)) - - this.#withCurrentNavigationElement(element, () => { - frame.src = url - }) + #navigateFrame(element, url) { + const frame = this.#findFrameElement(element) + frame.delegate.visit(FrameVisit.optionsForClick(element, expandURL(url))) } - proposeVisitIfNavigatedWithAction(frame, action = null) { - this.action = action - - if (this.action) { - const pageSnapshot = PageSnapshot.fromElement(frame).clone() - const { visitCachedSnapshot } = frame.delegate + async #proposeVisitIfNavigatedWithAction(frameVisit, fetchResponse) { + const { frameElement } = frameVisit - frame.delegate.fetchResponseLoaded = async (fetchResponse) => { - if (frame.src) { - const { statusCode, redirected } = fetchResponse - const responseHTML = await fetchResponse.responseHTML - const response = { statusCode, redirected, responseHTML } - const options = { - response, - visitCachedSnapshot, - willRender: false, - updateHistory: false, - restorationIdentifier: this.restorationIdentifier, - snapshot: pageSnapshot - } - - if (this.action) options.action = this.action - - session.visit(frame.src, options) - } + if (frameElement.src && frameVisit.action) { + const { statusCode, redirected } = fetchResponse + const responseHTML = await fetchResponse.responseHTML + const response = { statusCode, redirected, responseHTML } + const options = { + response, + visitCachedSnapshot: frameElement.delegate.visitCachedSnapshot, + willRender: false, + updateHistory: false, + restorationIdentifier: this.restorationIdentifier, + snapshot: frameVisit.snapshot, + action: frameVisit.action } + + session.visit(frameElement.src, options) } } - changeHistory() { - if (this.action) { - const method = getHistoryMethodForAction(this.action) + changeHistory(visitAction) { + if (visitAction) { + const method = getHistoryMethodForAction(visitAction) session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier) } } @@ -384,7 +309,7 @@ export class FrameController { } #willHandleFrameMissingFromResponse(fetchResponse) { - this.element.setAttribute("complete", "") + this.complete = true const response = fetchResponse.response const visit = async (url, options) => { @@ -512,7 +437,7 @@ export class FrameController { } get isLoading() { - return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined + return !!this.#frameVisit } get complete() { @@ -546,12 +471,6 @@ export class FrameController { callback() this.#ignoredAttributes.delete(attributeName) } - - #withCurrentNavigationElement(element, callback) { - this.currentNavigationElement = element - callback() - delete this.currentNavigationElement - } } function getFrameElementById(id) { diff --git a/src/core/frames/frame_visit.js b/src/core/frames/frame_visit.js new file mode 100644 index 000000000..834a7494a --- /dev/null +++ b/src/core/frames/frame_visit.js @@ -0,0 +1,152 @@ +import { expandURL } from "../url" +import { clearBusyState, getVisitAction, markAsBusy } from "../../util" +import { FetchMethod, FetchRequest } from "../../http/fetch_request" +import { FormSubmission } from "../drive/form_submission" +import { PageSnapshot } from "../drive/page_snapshot" +import { StreamMessage } from "../streams/stream_message" + +export class FrameVisit { + isFormSubmission = false + #resolveVisitPromise = () => {} + + static optionsForClick(element, url) { + const action = getVisitAction(element) + const acceptsStreamResponse = element.hasAttribute("data-turbo-stream") + + return { acceptsStreamResponse, action, url } + } + + static optionsForSubmit(form, submitter) { + const action = getVisitAction(form, submitter) + + return { action, submit: { form, submitter } } + } + + constructor(delegate, frameElement, options) { + this.delegate = delegate + this.frameElement = frameElement + this.previousURL = this.frameElement.src + + const { acceptsStreamResponse, action, url, submit } = (this.options = options) + + this.acceptsStreamResponse = acceptsStreamResponse || false + this.action = action || getVisitAction(this.frameElement) + + if (submit) { + const { fetchRequest } = (this.formSubmission = new FormSubmission(this, submit.form, submit.submitter)) + this.prepareRequest(fetchRequest) + this.isFormSubmission = true + this.isSafe = this.formSubmission.isSafe + } else if (url) { + this.fetchRequest = new FetchRequest(this, FetchMethod.get, expandURL(url), new URLSearchParams(), this.frameElement) + this.isSafe = true + } else { + throw new Error("FrameVisit must be constructed with either a url: or submit: option") + } + } + + async start() { + if (this.delegate.shouldVisitFrame(this)) { + if (this.action) { + this.snapshot = PageSnapshot.fromElement(this.frameElement).clone() + } + + if (this.formSubmission) { + await this.formSubmission.start() + } else { + await this.#performRequest() + } + + return this.frameElement.loaded + } else { + return Promise.resolve() + } + } + + stop() { + this.fetchRequest?.cancel() + this.formSubmission?.stop() + } + + // Fetch request delegate + + prepareRequest(fetchRequest) { + fetchRequest.headers["Turbo-Frame"] = this.frameElement.id + + if (this.acceptsStreamResponse || this.isFormSubmission) { + fetchRequest.acceptResponseType(StreamMessage.contentType) + } + } + + requestStarted(fetchRequest) { + this.delegate.frameVisitStarted(this) + + if (fetchRequest.target instanceof HTMLFormElement) { + markAsBusy(fetchRequest.target) + } + + markAsBusy(this.frameElement) + } + + requestPreventedHandlingResponse(_fetchRequest, _fetchResponse) { + this.#resolveVisitPromise() + } + + requestFinished(fetchRequest) { + clearBusyState(this.frameElement) + + if (fetchRequest.target instanceof HTMLFormElement) { + clearBusyState(fetchRequest.target) + } + + this.delegate.frameVisitCompleted(this) + } + + async requestSucceededWithResponse(_fetchRequest, fetchResponse) { + await this.delegate.frameVisitSucceededWithResponse(this, fetchResponse) + this.#resolveVisitPromise() + } + + async requestFailedWithResponse(_fetchRequest, fetchResponse) { + console.error(fetchResponse) + await this.delegate.frameVisitFailedWithResponse(this, fetchResponse) + this.#resolveVisitPromise() + } + + requestErrored(fetchRequest, error) { + this.delegate.frameVisitErrored(this, fetchRequest, error) + this.#resolveVisitPromise() + } + + // Form submission delegate + + formSubmissionStarted(formSubmission) { + this.requestStarted(formSubmission.fetchRequest) + } + + async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) { + await this.requestSucceededWithResponse(formSubmission.fetchRequest, fetchResponse) + } + + async formSubmissionFailedWithResponse(formSubmission, fetchResponse) { + await this.requestFailedWithResponse(formSubmission.fetchRequest, fetchResponse) + } + + formSubmissionErrored(formSubmission, error) { + this.requestErrored(formSubmission.fetchRequest, error) + } + + formSubmissionFinished(formSubmission) { + this.requestFinished(formSubmission.fetchRequest) + } + + #performRequest() { + this.frameElement.loaded = new Promise((resolve) => { + this.#resolveVisitPromise = () => { + this.#resolveVisitPromise = () => {} + resolve() + } + this.fetchRequest?.perform() + }) + } +} diff --git a/src/core/session.js b/src/core/session.js index cdb978348..bde580993 100644 --- a/src/core/session.js +++ b/src/core/session.js @@ -98,10 +98,10 @@ export class Session { const frameElement = options.frame ? document.getElementById(options.frame) : null if (frameElement instanceof FrameElement) { - const action = options.action || getVisitAction(frameElement) - - frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action) - frameElement.src = location.toString() + frameElement.delegate.visit({ + url: location.toString(), + action: options.action || getVisitAction(frameElement) + }) } else { this.navigator.proposeVisit(expandURL(location), options) } diff --git a/src/tests/fixtures/tabs/three.html b/src/tests/fixtures/tabs/three.html index 517d0dc2b..f2749777a 100644 --- a/src/tests/fixtures/tabs/three.html +++ b/src/tests/fixtures/tabs/three.html @@ -2,11 +2,13 @@ - Frame + Tabs: Three +

Tabs: Three

+
Tab 1 diff --git a/src/tests/fixtures/tabs/two.html b/src/tests/fixtures/tabs/two.html index 3f31b87f2..370b233df 100644 --- a/src/tests/fixtures/tabs/two.html +++ b/src/tests/fixtures/tabs/two.html @@ -7,6 +7,8 @@ +

Tabs: Two

+
Tab 1 diff --git a/src/tests/functional/frame_navigation_tests.js b/src/tests/functional/frame_navigation_tests.js index 9927aec87..11d011e16 100644 --- a/src/tests/functional/frame_navigation_tests.js +++ b/src/tests/functional/frame_navigation_tests.js @@ -1,5 +1,5 @@ import { test } from "@playwright/test" -import { getFromLocalStorage, nextBeat, nextEventNamed, nextEventOnTarget, pathname, scrollToSelector } from "../helpers/page" +import { getFromLocalStorage, nextBeat, nextEventNamed, nextEventOnTarget, pathname, scrollToSelector, sleep } from "../helpers/page" import { assert } from "chai" test("frame navigation with descendant link", async ({ page }) => { @@ -51,6 +51,90 @@ test("frame navigation emits fetch-request-error event when offline", async ({ p await nextEventOnTarget(page, "tab-frame", "turbo:fetch-request-error") }) +test("promoted frame submits a single request per navigation", async ({ page }) => { + await page.goto("/src/tests/fixtures/tabs.html") + await nextEventNamed(page, "turbo:load") + + const requestedPathnames = await capturingRequestPathnames(page, async () => { + await page.click("#tab-2") + await nextEventNamed(page, "turbo:load") + await page.click("#tab-3") + await nextEventNamed(page, "turbo:load") + }) + + assert.deepEqual(requestedPathnames, ["/src/tests/fixtures/tabs/two.html", "/src/tests/fixtures/tabs/three.html"]) +}) + +test("promoted frames do not submit requests when navigating back and forward with history", async ({ page }) => { + await page.goto("/src/tests/fixtures/tabs.html") + await nextEventNamed(page, "turbo:load") + await page.click("#tab-2") + await nextEventNamed(page, "turbo:load") + await page.click("#tab-3") + await nextEventNamed(page, "turbo:load") + + const requestedPathnames = await capturingRequestPathnames(page, async () => { + await page.goBack() + await nextEventNamed(page, "turbo:load") + await page.goForward() + await nextEventNamed(page, "turbo:load") + }) + + assert.deepEqual(requestedPathnames, []) +}) + +test("navigating back when frame navigation has been canceled does not submit a request", async ({ page }) => { + await page.goto("/src/tests/fixtures/tabs/three.html") + await nextEventNamed(page, "turbo:load") + await delayResponseForLink(page, "#tab-2", 2000) + page.click("#tab-2") + await page.click("#tab-1") + await nextEventNamed(page, "turbo:load") + + assert.equal("/src/tests/fixtures/tabs.html", pathname(page.url())) + + const requestedPathnames = await capturingRequestPathnames(page, async () => { + await page.goBack() + await nextEventNamed(page, "turbo:load") + }) + + assert.deepEqual([], requestedPathnames) +}) + +test("canceling frame requests don't mutate the history", async ({ page }) => { + await page.goto("/src/tests/fixtures/tabs.html") + await page.click("#tab-2") + await nextEventOnTarget(page, "tab-frame", "turbo:frame-load") + await nextEventNamed(page, "turbo:load") + + assert.equal(await page.textContent("#tab-content"), "Two") + assert.equal(pathname((await page.getAttribute("#tab-frame", "src")) || ""), "/src/tests/fixtures/tabs/two.html") + assert.equal(await page.getAttribute("#tab-frame", "complete"), "", "sets [complete]") + + // This request will be canceled + await delayResponseForLink(page, "#tab-1", 2000) + page.click("#tab-1") + await page.click("#tab-3") + + await nextEventOnTarget(page, "tab-frame", "turbo:frame-load") + await nextEventNamed(page, "turbo:load") + + assert.equal(await page.textContent("#tab-content"), "Three") + assert.equal(pathname((await page.getAttribute("#tab-frame", "src")) || ""), "/src/tests/fixtures/tabs/three.html") + + await page.goBack() + await nextEventNamed(page, "turbo:load") + + assert.equal(await page.textContent("#tab-content"), "Two") + assert.equal(pathname((await page.getAttribute("#tab-frame", "src")) || ""), "/src/tests/fixtures/tabs/two.html") + + // Make sure the frame is not mutated after some time. + await nextBeat() + + assert.equal(await page.textContent("#tab-content"), "Two") + assert.equal(pathname((await page.getAttribute("#tab-frame", "src")) || ""), "/src/tests/fixtures/tabs/two.html") +}) + test("lazy-loaded frame promotes navigation", async ({ page }) => { await page.goto("/src/tests/fixtures/frame_navigation.html") @@ -118,3 +202,24 @@ test("promoted frame navigations are cached", async ({ page }) => { assert.equal(await page.getAttribute("#tab-frame", "src"), null, "caches one.html without #tab-frame[src]") assert.equal(await page.getAttribute("#tab-frame", "complete"), null, "caches one.html without [complete]") }) + +async function capturingRequestPathnames(page, callback) { + const requestedPathnames = [] + + page.on("request", (request) => requestedPathnames.push(pathname(request.url()))) + + await callback() + + return requestedPathnames +} + +async function delayResponseForLink(page, selector, delayInMilliseconds) { + const href = await page.locator(selector).evaluate((link) => link.href) + + await page.route(href, async (route) => { + await sleep(delayInMilliseconds) + route.continue() + }) + + return page +} diff --git a/src/tests/functional/frame_tests.js b/src/tests/functional/frame_tests.js index f1925908b..6fa904e7a 100644 --- a/src/tests/functional/frame_tests.js +++ b/src/tests/functional/frame_tests.js @@ -29,19 +29,31 @@ test.beforeEach(async ({ page }) => { }) test("navigating a frame with Turbo.visit", async ({ page }) => { - const pathname = "/src/tests/fixtures/frames/frame.html" + const path = "/src/tests/fixtures/frames/frame.html" await page.locator("#frame").evaluate((frame) => frame.setAttribute("disabled", "")) - await page.evaluate((pathname) => window.Turbo.visit(pathname, { frame: "frame" }), pathname) + await page.evaluate((path) => window.Turbo.visit(path, { frame: "frame" }), path) await nextBeat() assert.equal(await page.textContent("#frame h2"), "Frames: #frame", "does not navigate a disabled frame") await page.locator("#frame").evaluate((frame) => frame.removeAttribute("disabled")) - await page.evaluate((pathname) => window.Turbo.visit(pathname, { frame: "frame" }), pathname) - await nextBeat() + await page.evaluate((path) => window.Turbo.visit(path, { frame: "frame" }), path) + await nextEventOnTarget(page, "frame", "turbo:frame-load") + + assert.equal(await page.textContent("#frame h2"), "Frame: Loaded", "navigates the target frame") + assert.equal(pathname(page.url()), "/src/tests/fixtures/frames.html") + assert.equal(pathname((await attributeForSelector(page, "#frame", "src")) || ""), path) +}) + +test("navigating a frame with Turbo.visit and an action: option", async ({ page }) => { + const path = "/src/tests/fixtures/frames/frame.html" + await page.evaluate((path) => window.Turbo.visit(path, { frame: "frame", action: "advance" }), path) + await nextEventOnTarget(page, "frame", "turbo:frame-load") assert.equal(await page.textContent("#frame h2"), "Frame: Loaded", "navigates the target frame") + assert.equal(pathname(page.url()), path) + assert.equal(pathname((await attributeForSelector(page, "#frame", "src")) || ""), path) }) test("navigating a frame a second time does not leak event listeners", async ({ page }) => {