diff --git a/src/core/drive/form_submission.ts b/src/core/drive/form_submission.ts index babeb1259..48ed94cd4 100644 --- a/src/core/drive/form_submission.ts +++ b/src/core/drive/form_submission.ts @@ -1,7 +1,7 @@ import { FetchRequest, FetchMethod, fetchMethodFromString, FetchRequestHeaders } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { expandURL } from "../url" -import { dispatch, getMetaContent } from "../../util" +import { clearBusyState, dispatch, getMetaContent, markAsBusy } from "../../util" import { StreamMessage } from "../streams/stream_message" export interface FormSubmissionDelegate { @@ -167,6 +167,7 @@ export class FormSubmission { requestStarted(_request: FetchRequest) { this.state = FormSubmissionState.waiting + markAsBusy(this.formElement) this.submitter?.setAttribute("disabled", "") dispatch("turbo:submit-start", { target: this.formElement, @@ -205,6 +206,7 @@ export class FormSubmission { requestFinished(_request: FetchRequest) { this.state = FormSubmissionState.stopped this.submitter?.removeAttribute("disabled") + clearBusyState(this.formElement) dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }, diff --git a/src/core/drive/navigator.ts b/src/core/drive/navigator.ts index e52e63b92..596e603a6 100644 --- a/src/core/drive/navigator.ts +++ b/src/core/drive/navigator.ts @@ -1,10 +1,9 @@ -import { Action, isAction } from "../types" +import { Action } 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 { getAttribute } from "../../util" -import { Visit, VisitDelegate, VisitOptions } from "./visit" +import { Visit, VisitDelegate, VisitOptions, getVisitAction } from "./visit" import { PageSnapshot } from "./page_snapshot" export type NavigatorDelegate = VisitDelegate & { @@ -173,9 +172,7 @@ export class Navigator { return this.history.restorationIdentifier } - getActionForFormSubmission(formSubmission: FormSubmission): Action { - const { formElement, submitter } = formSubmission - const action = getAttribute("data-turbo-action", submitter, formElement) - return isAction(action) ? action : "advance" + getActionForFormSubmission({ formElement, submitter }: FormSubmission): Action { + return getVisitAction(submitter, formElement) || "advance" } } diff --git a/src/core/drive/visit.ts b/src/core/drive/visit.ts index a0f92293c..3cbf48fa8 100644 --- a/src/core/drive/visit.ts +++ b/src/core/drive/visit.ts @@ -5,8 +5,8 @@ import { History } from "./history" import { getAnchor } from "../url" import { Snapshot } from "../snapshot" import { PageSnapshot } from "./page_snapshot" -import { Action, ResolvingFunctions } from "../types" -import { getHistoryMethodForAction, uuid } from "../../util" +import { Action, ResolvingFunctions, isAction } from "../types" +import { getAttribute, getHistoryMethodForAction, uuid } from "../../util" import { PageView } from "./page_view" export interface VisitDelegate { @@ -472,6 +472,12 @@ export class Visit implements FetchRequestDelegate { } } +export function getVisitAction(...elements: (Element | undefined)[]): Action | null { + const action = getAttribute("data-turbo-action", ...elements) + + return isAction(action) ? action : null +} + function isSuccessful(statusCode: number) { return statusCode >= 200 && statusCode < 300 } diff --git a/src/core/frames/frame_controller.ts b/src/core/frames/frame_controller.ts index 38d57fdd3..a9eaf1c31 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.ts @@ -4,7 +4,7 @@ import { FrameLoadingStyle, FrameElementObservedAttribute, } from "../../elements/frame_element" -import { FetchMethod, FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request" +import { FrameVisit, FrameVisitDelegate, FrameVisitOptions } from "./frame_visit" import { FetchResponse } from "../../http/fetch_response" import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer" import { @@ -15,9 +15,7 @@ import { markAsBusy, uuid, getHistoryMethodForAction, - getVisitAction, } from "../../util" -import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission" import { Snapshot } from "../snapshot" import { ViewDelegate, ViewRenderOptions } from "../view" import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url" @@ -27,18 +25,17 @@ import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" import { FormLinkClickObserver, FormLinkClickObserverDelegate } from "../../observers/form_link_click_observer" import { FrameRenderer } from "./frame_renderer" import { session } from "../index" -import { isAction, Action } from "../types" +import { Action } from "../types" import { VisitOptions } from "../drive/visit" import { TurboBeforeFrameRenderEvent } from "../session" export class FrameController implements AppearanceObserverDelegate, - FetchRequestDelegate, FormSubmitObserverDelegate, - FormSubmissionDelegate, FrameElementDelegate, FormLinkClickObserverDelegate, + FrameVisitDelegate, LinkInterceptorDelegate, ViewDelegate> { @@ -48,15 +45,10 @@ export class FrameController readonly formLinkClickObserver: FormLinkClickObserver readonly linkInterceptor: LinkInterceptor readonly formSubmitObserver: FormSubmitObserver - formSubmission?: FormSubmission - fetchResponseLoaded = (_fetchResponse: FetchResponse) => {} - private currentFetchRequest: FetchRequest | null = null - private resolveVisitPromise = () => {} + frameVisit?: FrameVisit private connected = false private hasBeenLoaded = false private ignoredAttributes: Set = new Set() - private action: Action | null = null - private frame?: FrameElement readonly restorationIdentifier: string private previousFrameElement?: FrameElement @@ -76,7 +68,7 @@ export class FrameController if (this.loadingStyle == FrameLoadingStyle.lazy) { this.appearanceObserver.start() } else { - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } this.formLinkClickObserver.start() this.linkInterceptor.start() @@ -96,7 +88,7 @@ export class FrameController disabledChanged() { if (this.loadingStyle == FrameLoadingStyle.eager) { - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } } @@ -108,14 +100,14 @@ export class FrameController } if (this.loadingStyle == FrameLoadingStyle.eager || this.hasBeenLoaded) { - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } } completeChanged() { if (this.isIgnoringChangesTo("complete")) return - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } loadingStyleChanged() { @@ -123,20 +115,62 @@ export class FrameController this.appearanceObserver.start() } else { this.appearanceObserver.stop() - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } } - private 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 + visit(options: Partial = {}): Promise { + const frameVisit = new FrameVisit(this, this.element, options) + return frameVisit.start() + } + + submit(options: Partial = {}): Promise { + const { submit } = options + + if (submit) { + const frameVisit = new FrameVisit(this, this.element, options) + return frameVisit.start() + } else { + return Promise.reject() } } - async loadResponse(fetchResponse: FetchResponse) { + // Frame visit delegate + + shouldVisit({ isFormSubmission }: FrameVisit) { + return this.enabled && this.isActive && (!this.complete || isFormSubmission) + } + + visitStarted(frameVisit: FrameVisit) { + this.frameVisit?.stop() + this.frameVisit = frameVisit + + this.appearanceObserver.stop() + markAsBusy(this.element) + } + + async visitSucceeded({ action }: FrameVisit, response: FetchResponse) { + await this.loadResponse(response, action) + } + + async visitFailed({ action }: FrameVisit, response: FetchResponse) { + await this.loadResponse(response, action) + } + + visitErrored(frameVisit: FrameVisit, error: Error) { + console.error(error) + this.view.invalidate() + throw error + } + + visitCompleted(_frameVisit: FrameVisit) { + clearBusyState(this.element) + this.hasBeenLoaded = true + } + + async loadResponse(fetchResponse: FetchResponse, action: Action | null) { + const fetchResponseLoaded = this.proposeVisitIfNavigatedWithAction(this.element, action) + if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { this.sourceURL = fetchResponse.response.url } @@ -148,26 +182,24 @@ export class FrameController const snapshot = new Snapshot(await this.extractForeignFrameElement(body)) const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false) if (this.view.renderPromise) await this.view.renderPromise - this.changeHistory() + this.changeHistory(action) await this.view.render(renderer) this.complete = true session.frameRendered(fetchResponse, this.element) session.frameLoaded(this.element) - this.fetchResponseLoaded(fetchResponse) + fetchResponseLoaded(fetchResponse) } } catch (error) { console.error(error) this.view.invalidate() - } finally { - this.fetchResponseLoaded = () => {} } } // Appearance observer delegate elementAppearedInViewport(_element: Element) { - this.loadSourceURL() + this.visit({ url: this.sourceURL }) } // Form link click observer delegate @@ -188,7 +220,9 @@ export class FrameController } linkClickIntercepted(element: Element, url: string) { - this.navigateFrame(element, url) + const frame = this.findFrameElement(element) + frame.removeAttribute("complete") + frame.delegate.visit(FrameVisit.optionsForClick(element, url)) } // Form submit observer delegate @@ -198,73 +232,9 @@ export class FrameController } formSubmitted(element: HTMLFormElement, submitter?: HTMLElement) { - if (this.formSubmission) { - this.formSubmission.stop() - } - - this.formSubmission = new FormSubmission(this, element, submitter) - const { fetchRequest } = this.formSubmission - this.prepareHeadersForRequest(fetchRequest.headers, fetchRequest) - this.formSubmission.start() - } - - // Fetch request delegate - - prepareHeadersForRequest(headers: FetchRequestHeaders, _request: FetchRequest) { - headers["Turbo-Frame"] = this.id - } - - requestStarted(_request: FetchRequest) { - markAsBusy(this.element) - } - - requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) { - this.resolveVisitPromise() - } - - async requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) { - await this.loadResponse(response) - this.resolveVisitPromise() - } - - requestFailedWithResponse(request: FetchRequest, response: FetchResponse) { - console.error(response) - this.resolveVisitPromise() - } - - requestErrored(request: FetchRequest, error: Error) { - console.error(error) - this.resolveVisitPromise() - } - - requestFinished(_request: FetchRequest) { - clearBusyState(this.element) - } - - // Form submission delegate - - formSubmissionStarted({ formElement }: FormSubmission) { - markAsBusy(formElement, this.findFrameElement(formElement)) - } - - formSubmissionSucceededWithResponse(formSubmission: FormSubmission, response: FetchResponse) { - const frame = this.findFrameElement(formSubmission.formElement, formSubmission.submitter) - - this.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter) - - frame.delegate.loadResponse(response) - } - - formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) { - this.element.delegate.loadResponse(fetchResponse) - } - - formSubmissionErrored(formSubmission: FormSubmission, error: Error) { - console.error(error) - } - - formSubmissionFinished({ formElement }: FormSubmission) { - clearBusyState(formElement, this.findFrameElement(formElement)) + const frame = this.findFrameElement(element, submitter) + frame.removeAttribute("complete") + frame.delegate.submit(FrameVisit.optionsForSubmit(element, submitter)) } // View delegate @@ -312,38 +282,13 @@ export class FrameController // Private - private async visit(url: 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() - }) - } - - private navigateFrame(element: Element, url: string, submitter?: HTMLElement) { - const frame = this.findFrameElement(element, submitter) - - this.proposeVisitIfNavigatedWithAction(frame, element, submitter) - - frame.src = url - } - - private proposeVisitIfNavigatedWithAction(frame: FrameElement, element: Element, submitter?: HTMLElement) { - this.action = getVisitAction(submitter, element, frame) - this.frame = frame - - if (isAction(this.action)) { + private proposeVisitIfNavigatedWithAction( + frame: FrameElement, + action: Action | null + ): (fetchResponse: FetchResponse) => void { + if (action) { const { visitCachedSnapshot } = frame.delegate - - frame.delegate.fetchResponseLoaded = (fetchResponse: FetchResponse) => { + return (fetchResponse: FetchResponse) => { if (frame.src) { const { statusCode, redirected } = fetchResponse const responseHTML = frame.ownerDocument.documentElement.outerHTML @@ -356,18 +301,18 @@ export class FrameController restorationIdentifier: this.restorationIdentifier, } - if (this.action) options.action = this.action - session.visit(frame.src, options) } } + } else { + return () => {} } } - changeHistory() { - if (this.action && this.frame) { - const method = getHistoryMethodForAction(this.action) - session.history.update(method, expandURL(this.frame.src || ""), this.restorationIdentifier) + changeHistory(action: Action | null) { + if (action) { + const method = getHistoryMethodForAction(action) + session.history.update(method, expandURL(this.sourceURL || ""), this.restorationIdentifier) } } @@ -462,7 +407,7 @@ export class FrameController } get isLoading() { - return this.formSubmission !== undefined || this.resolveVisitPromise() !== undefined + return this.frameVisit !== undefined } get complete() { diff --git a/src/core/frames/frame_redirector.ts b/src/core/frames/frame_redirector.ts index 93c9bbc5f..15418a719 100644 --- a/src/core/frames/frame_redirector.ts +++ b/src/core/frames/frame_redirector.ts @@ -2,6 +2,7 @@ import { FormSubmitObserver, FormSubmitObserverDelegate } from "../../observers/ import { FrameElement } from "../../elements/frame_element" import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor" import { expandURL, getAction, locationIsVisitable } from "../url" +import { FrameVisit } from "./frame_visit" export class FrameRedirector implements LinkInterceptorDelegate, FormSubmitObserverDelegate { readonly element: Element @@ -31,7 +32,8 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormSubmitObser linkClickIntercepted(element: Element, url: string) { const frame = this.findFrameElement(element) if (frame) { - frame.delegate.linkClickIntercepted(element, url) + frame.removeAttribute("complete") + frame.delegate.visit(FrameVisit.optionsForClick(element, url)) } } @@ -46,7 +48,8 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormSubmitObser formSubmitted(element: HTMLFormElement, submitter?: HTMLElement) { const frame = this.findFrameElement(element, submitter) if (frame) { - frame.delegate.formSubmitted(element, submitter) + frame.removeAttribute("complete") + frame.delegate.submit(FrameVisit.optionsForSubmit(element, submitter)) } } diff --git a/src/core/frames/frame_visit.ts b/src/core/frames/frame_visit.ts new file mode 100644 index 000000000..f9e67b454 --- /dev/null +++ b/src/core/frames/frame_visit.ts @@ -0,0 +1,148 @@ +import { expandURL } from "../url" +import { Action } from "../types" +import { getVisitAction } from "../drive/visit" +import { FrameElement } from "../../elements/frame_element" +import { FetchRequest, FetchRequestHeaders, FetchRequestDelegate, FetchMethod } from "../../http/fetch_request" +import { FetchResponse } from "../../http/fetch_response" +import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission" + +export interface FrameVisitOptions { + action: Action | null + submit: { form: HTMLFormElement; submitter?: HTMLElement } + url: string +} + +export interface FrameVisitDelegate { + shouldVisit(frameVisit: FrameVisit): boolean + visitStarted(frameVisit: FrameVisit): void + visitSucceeded(frameVisit: FrameVisit, response: FetchResponse): void + visitFailed(frameVisit: FrameVisit, response: FetchResponse): void + visitErrored(frameVisit: FrameVisit, error: Error): void + visitCompleted(frameVisit: FrameVisit): void +} + +export class FrameVisit implements FetchRequestDelegate, FormSubmissionDelegate { + readonly delegate: FrameVisitDelegate + readonly element: FrameElement + readonly action: Action | null + readonly previousURL: string | null + readonly options: Partial + readonly isFormSubmission: boolean = false + + private readonly fetchRequest?: FetchRequest + private readonly formSubmission?: FormSubmission + private resolveVisitPromise = () => {} + + static optionsForClick(element: Element, url: string): Partial { + const action = getVisitAction(element) + + return { action, url } + } + + static optionsForSubmit(form: HTMLFormElement, submitter?: HTMLElement): Partial { + const action = getVisitAction(form, submitter) + + return { action, submit: { form, submitter } } + } + + constructor(delegate: FrameVisitDelegate, element: FrameElement, options: Partial = {}) { + this.delegate = delegate + this.element = element + this.previousURL = this.element.src + const { action, url, submit } = (this.options = options) + + this.action = action || getVisitAction(this.element) + if (url) { + this.fetchRequest = new FetchRequest(this, FetchMethod.get, expandURL(url), new URLSearchParams(), this.element) + } else if (submit) { + const { fetchRequest } = (this.formSubmission = new FormSubmission(this, submit.form, submit.submitter)) + this.prepareHeadersForRequest(fetchRequest.headers) + this.isFormSubmission = true + } + } + + async start(): Promise { + if (this.delegate.shouldVisit(this)) { + if (this.formSubmission) { + await this.formSubmission.start() + } else { + await this.performRequest() + } + + return this.element.loaded + } else { + return Promise.reject() + } + } + + stop() { + this.fetchRequest?.cancel() + this.formSubmission?.stop() + } + + // Fetch request delegate + + prepareHeadersForRequest(headers: FetchRequestHeaders) { + headers["Turbo-Frame"] = this.element.id + } + + requestStarted(_request: FetchRequest) { + this.delegate.visitStarted(this) + } + + requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) { + this.resolveVisitPromise() + } + + requestFinished(_request: FetchRequest) { + this.delegate.visitCompleted(this) + } + + async requestSucceededWithResponse(fetchRequest: FetchRequest, fetchResponse: FetchResponse) { + await this.delegate.visitSucceeded(this, fetchResponse) + this.resolveVisitPromise() + } + + async requestFailedWithResponse(request: FetchRequest, fetchResponse: FetchResponse) { + console.error(fetchResponse) + await this.delegate.visitFailed(this, fetchResponse) + this.resolveVisitPromise() + } + + requestErrored(request: FetchRequest, error: Error) { + this.delegate.visitErrored(this, error) + this.resolveVisitPromise() + } + + // Form submission delegate + + formSubmissionStarted({ fetchRequest }: FormSubmission) { + this.requestStarted(fetchRequest) + } + + async formSubmissionSucceededWithResponse({ fetchRequest }: FormSubmission, response: FetchResponse) { + await this.requestSucceededWithResponse(fetchRequest, response) + } + + async formSubmissionFailedWithResponse({ fetchRequest }: FormSubmission, fetchResponse: FetchResponse) { + await this.requestFailedWithResponse(fetchRequest, fetchResponse) + } + + formSubmissionErrored({ fetchRequest }: FormSubmission, error: Error) { + this.requestErrored(fetchRequest, error) + } + + formSubmissionFinished({ fetchRequest }: FormSubmission) { + this.requestFinished(fetchRequest) + } + + private performRequest() { + this.element.loaded = new Promise((resolve) => { + this.resolveVisitPromise = () => { + this.resolveVisitPromise = () => {} + resolve() + } + this.fetchRequest?.perform() + }) + } +} diff --git a/src/core/session.ts b/src/core/session.ts index 2e68cacd6..4676629a7 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -12,10 +12,10 @@ import { PageObserver, PageObserverDelegate } from "../observers/page_observer" import { ScrollObserver } from "../observers/scroll_observer" import { StreamMessage } from "./streams/stream_message" import { StreamObserver } from "../observers/stream_observer" -import { Action, Position, StreamSource, isAction } from "./types" +import { Action, Position, StreamSource } from "./types" import { clearBusyState, dispatch, markAsBusy } from "../util" import { PageView, PageViewDelegate, PageViewRenderOptions } from "./drive/page_view" -import { Visit, VisitOptions } from "./drive/visit" +import { Visit, VisitOptions, getVisitAction } from "./drive/visit" import { PageSnapshot } from "./drive/page_snapshot" import { FrameElement } from "../elements/frame_element" import { FrameViewRenderOptions } from "./frames/frame_view" @@ -110,8 +110,7 @@ export class Session const frameElement = document.getElementById(options.frame || "") if (frameElement instanceof FrameElement) { - frameElement.src = location.toString() - return frameElement.loaded + return frameElement.delegate.visit({ url: location.toString() }) } else { return this.navigator.proposeVisit(expandURL(location), options) } @@ -418,8 +417,7 @@ export class Session // Private getActionForLink(link: Element): Action { - const action = link.getAttribute("data-turbo-action") - return isAction(action) ? action : "advance" + return getVisitAction(link) || "advance" } get snapshot() { diff --git a/src/elements/frame_element.ts b/src/elements/frame_element.ts index 3e7676cad..5276e989a 100644 --- a/src/elements/frame_element.ts +++ b/src/elements/frame_element.ts @@ -1,7 +1,5 @@ -import { FetchResponse } from "../http/fetch_response" +import { FrameVisitOptions } from "../core/frames/frame_visit" import { Snapshot } from "../core/snapshot" -import { LinkInterceptorDelegate } from "../core/frames/link_interceptor" -import { FormSubmitObserverDelegate } from "../observers/form_submit_observer" export enum FrameLoadingStyle { eager = "eager", @@ -10,15 +8,15 @@ export enum FrameLoadingStyle { export type FrameElementObservedAttribute = keyof FrameElement & ("disabled" | "complete" | "loading" | "src") -export interface FrameElementDelegate extends LinkInterceptorDelegate, FormSubmitObserverDelegate { +export interface FrameElementDelegate { connect(): void disconnect(): void completeChanged(): void loadingStyleChanged(): void sourceURLChanged(): void disabledChanged(): void - loadResponse(response: FetchResponse): void - fetchResponseLoaded: (fetchResponse: FetchResponse) => void + visit(options: Partial): Promise + submit(options: Partial): Promise visitCachedSnapshot: (snapshot: Snapshot) => void isLoading: boolean } diff --git a/src/tests/fixtures/frames.html b/src/tests/fixtures/frames.html index b611136e5..be489ff52 100644 --- a/src/tests/fixtures/frames.html +++ b/src/tests/fixtures/frames.html @@ -31,8 +31,11 @@

Frames: #frame

Navigate #frame from within Navigate #frame with ?key=value Navigate #frame from within with a[data-turbo-action="advance"] + inside #frame: 500 Navigate #frame to /frames/form.html + outside #frame: 500 + Navigate #frame from outside with a[data-turbo-action="advance"]
diff --git a/src/tests/functional/frame_tests.ts b/src/tests/functional/frame_tests.ts index e5e160433..816e99178 100644 --- a/src/tests/functional/frame_tests.ts +++ b/src/tests/functional/frame_tests.ts @@ -36,9 +36,14 @@ test("test navigating a frame with Turbo.visit", async ({ page }) => { const pathname = "/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) + const rejected = await page.evaluate((pathname) => { + return window.Turbo.visit(pathname, { frame: "frame" }) + .then(() => false) + .catch(() => true) + }, pathname) await nextBeat() + assert.ok(rejected, "Turbo.visit-ing a [disabled] frame results in a Promise rejection") assert.equal(await page.textContent("#frame h2"), "Frames: #frame", "does not navigate a disabled frame") await page.locator("#frame").evaluate((frame) => frame.removeAttribute("disabled")) @@ -96,6 +101,22 @@ test("test a frame whose src references itself does not infinitely loop", async assert.equal(otherEvents.length, 0, "no more events") }) +test("test following a link within a frame renders an error response", async ({ page }) => { + await page.click("#inside-frame-500") + await nextBeat() + + const title = await page.locator("#frame h2") + assert.equal(await title.textContent(), "Frame: Internal Server Error") +}) + +test("test following a link targetting a frame renders an error response", async ({ page }) => { + await page.click("#outside-frame-500") + await nextBeat() + + const title = await page.locator("#frame h2") + assert.equal(await title.textContent(), "Frame: Internal Server Error") +}) + test("test following a link driving a frame toggles the [aria-busy=true] attribute", async ({ page }) => { await page.click("#hello a") diff --git a/src/tests/server.ts b/src/tests/server.ts index 49a292e8f..d5853d132 100644 --- a/src/tests/server.ts +++ b/src/tests/server.ts @@ -57,6 +57,13 @@ router.post("/reject", (request, response) => { response.status(parseInt(status || "422", 10)).sendFile(fixture) }) +router.get("/internal_server_error", (request, response) => { + const status = request.params.status || "500" + const fixture = path.join(__dirname, `../../src/tests/fixtures/${status}.html`) + + response.status(parseInt(status, 10)).sendFile(fixture) +}) + router.get("/headers", (request, response) => { const template = fs.readFileSync("src/tests/fixtures/headers.html").toString() response