diff --git a/src/core/frames/frame_controller.ts b/src/core/frames/frame_controller.ts index 85254930d..19b216066 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.ts @@ -31,6 +31,8 @@ import { isAction, Action } from "../types" import { VisitOptions } from "../drive/visit" import { TurboBeforeFrameRenderEvent } from "../session" +export type TurboFrameMissingEvent = CustomEvent<{ fetchResponse: FetchResponse }> + export class FrameController implements AppearanceObserverDelegate, @@ -145,23 +147,29 @@ export class FrameController const html = await fetchResponse.responseHTML if (html) { const { body } = parseHTMLDocument(html) - const snapshot = new Snapshot(await this.extractForeignFrameElement(body)) - const renderer = new FrameRenderer( - this, - this.view.snapshot, - snapshot, - FrameRenderer.renderElement, - false, - false - ) - if (this.view.renderPromise) await this.view.renderPromise - this.changeHistory() - - await this.view.render(renderer) - this.complete = true - session.frameRendered(fetchResponse, this.element) - session.frameLoaded(this.element) - this.fetchResponseLoaded(fetchResponse) + const newFrameElement = await this.extractForeignFrameElement(body) + + 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() + + await this.view.render(renderer) + this.complete = true + session.frameRendered(fetchResponse, this.element) + session.frameLoaded(this.element) + this.fetchResponseLoaded(fetchResponse) + } else if (this.sessionWillHandleMissingFrame(fetchResponse)) { + await session.frameMissing(this.element, fetchResponse) + } } } catch (error) { console.error(error) @@ -378,12 +386,24 @@ export class FrameController } } + private sessionWillHandleMissingFrame(fetchResponse: FetchResponse) { + this.element.setAttribute("complete", "") + + const event = dispatch("turbo:frame-missing", { + target: this.element, + detail: { fetchResponse }, + cancelable: true, + }) + + return !event.defaultPrevented + } + private findFrameElement(element: Element, submitter?: HTMLElement) { const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target") return getFrameElementById(id) ?? this.element } - async extractForeignFrameElement(container: ParentNode): Promise { + async extractForeignFrameElement(container: ParentNode): Promise { let element const id = CSS.escape(this.id) @@ -398,13 +418,12 @@ export class FrameController await element.loaded return await this.extractForeignFrameElement(element) } - - console.error(`Response has no matching element`) } catch (error) { console.error(error) + return new FrameElement() } - return new FrameElement() + return null } private formActionIsVisitable(form: HTMLFormElement, submitter?: HTMLElement) { diff --git a/src/core/index.ts b/src/core/index.ts index 721d2989c..ed6f68484 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -27,6 +27,7 @@ export { } from "./session" export { TurboSubmitStartEvent, TurboSubmitEndEvent } from "./drive/form_submission" +export { TurboFrameMissingEvent } from "./frames/frame_controller" export { TurboBeforeFetchRequestEvent, TurboBeforeFetchResponseEvent } from "../http/fetch_request" export { TurboBeforeStreamRenderEvent } from "../elements/stream_element" diff --git a/src/core/session.ts b/src/core/session.ts index 23fcc1eaa..2411bd3d1 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -305,6 +305,13 @@ export class Session this.notifyApplicationAfterFrameRender(fetchResponse, frame) } + async frameMissing(frame: FrameElement, fetchResponse: FetchResponse): Promise { + const responseHTML = await fetchResponse.responseHTML + const { location, redirected, statusCode } = fetchResponse + + return this.visit(location, { response: { redirected, statusCode, responseHTML } }) + } + // Application events applicationAllowsFollowingLinkToLocation(link: Element, location: URL, ev: MouseEvent) { diff --git a/src/tests/fixtures/test.js b/src/tests/fixtures/test.js index acb543061..510e8c2ea 100644 --- a/src/tests/fixtures/test.js +++ b/src/tests/fixtures/test.js @@ -50,5 +50,6 @@ "turbo:before-frame-render", "turbo:frame-load", "turbo:frame-render", + "turbo:frame-missing", "turbo:reload" ]) diff --git a/src/tests/functional/frame_tests.ts b/src/tests/functional/frame_tests.ts index e5e160433..565b892b6 100644 --- a/src/tests/functional/frame_tests.ts +++ b/src/tests/functional/frame_tests.ts @@ -113,10 +113,59 @@ test("test following a link driving a frame toggles the [aria-busy=true] attribu ) }) -test("test following a link to a page without a matching frame results in an empty frame", async ({ page }) => { +test("test following a link to a page without a matching frame dispatches a turbo:frame-missing event", async ({ + page, +}) => { await page.click("#missing a") - await nextBeat() - assert.notOk(await innerHTMLForSelector(page, "#missing")) + await noNextEventOnTarget(page, "missing", "turbo:frame-render") + await noNextEventOnTarget(page, "missing", "turbo:frame-load") + const { fetchResponse } = await nextEventOnTarget(page, "missing", "turbo:frame-missing") + await nextEventNamed(page, "turbo:load") + + assert.ok(fetchResponse, "dispatchs turbo:frame-missing with event.detail.fetchResponse") + assert.equal(pathname(page.url()), "/src/tests/fixtures/frames/frame.html", "navigates the page") + + await page.goBack() + await nextEventNamed(page, "turbo:load") + + assert.equal(pathname(page.url()), "/src/tests/fixtures/frames.html") + assert.ok(await innerHTMLForSelector(page, "#missing")) +}) + +test("test following a link to a page without a matching frame dispatches a turbo:frame-missing event that can be cancelled", async ({ + page, +}) => { + await page.locator("#missing").evaluate((frame) => { + frame.addEventListener( + "turbo:frame-missing", + (event) => { + event.preventDefault() + + if (event.target instanceof HTMLElement) { + event.target.textContent = "Overridden" + } + }, + { once: true } + ) + }) + await page.click("#missing a") + await nextEventOnTarget(page, "missing", "turbo:frame-missing") + + assert.equal(await page.textContent("#missing"), "Overridden") +}) + +test("test following a link to a page with a matching frame does not dispatch a turbo:frame-missing event", async ({ + page, +}) => { + await page.click("#link-frame") + await noNextEventNamed(page, "turbo:frame-missing") + await nextEventOnTarget(page, "frame", "turbo:frame-load") + + const src = await attributeForSelector(page, "#frame", "src") + assert( + src?.includes("/src/tests/fixtures/frames/frame.html"), + "navigates frame without dispatching turbo:frame-missing" + ) }) test("test following a link within a frame with a target set navigates the target frame", async ({ page }) => {