Skip to content

Commit

Permalink
Introduce turbo:frame-missing event
Browse files Browse the repository at this point in the history
Closes [hotwired#432][]
Follow-up to [hotwired#94][]
Follow-up to [hotwired#31][]

When a response from _within_ a frame is missing a matching frame, fire
the `turbo:frame-missing` event.

There is an existing [contract][] that dictates a request from within a
frame stays within a frame.

However, if an application is interested in reacting to a response
without a frame, dispatch a `turbo:frame-missing` event. The event's
`target` is the `FrameElement`, and the `detail` contains the
`fetchResponse:` key. Unless it's canceled (by calling
`event.preventDefault()`), Turbo Drive will visit the frame's URL as a
full-page navigation.

The event listener is also a good opportunity to change the
`<turbo-frame>` element itself to prevent future missing responses.

For example, if the reason the frame is missing is access (an expired
session, for example), the call to `visit()` can be made with `{ action:
"replace" }` to remove the current page from Turbo's page history.

[contract]: hotwired#94 (comment)
[hotwired#432]: hotwired#432
[hotwired#94]: hotwired#94
[hotwired#31]: hotwired#31
  • Loading branch information
seanpdoyle committed Aug 1, 2022
1 parent d2443b6 commit 67c0826
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 24 deletions.
59 changes: 38 additions & 21 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -145,23 +147,39 @@ 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 element = await this.extractForeignFrameElement(body)

if (element) {
const snapshot = new Snapshot(element)
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 {
this.element.setAttribute("complete", "")

const event = dispatch<TurboFrameMissingEvent>("turbo:frame-missing", {
target: this.element,
detail: { fetchResponse },
cancelable: true,
})

if (!event.defaultPrevented) {
await session.frameMissing(this.element, fetchResponse)
}
}
}
} catch (error) {
console.error(error)
Expand Down Expand Up @@ -383,7 +401,7 @@ export class FrameController
return getFrameElementById(id) ?? this.element
}

async extractForeignFrameElement(container: ParentNode): Promise<FrameElement> {
async extractForeignFrameElement(container: ParentNode): Promise<FrameElement | null> {
let element
const id = CSS.escape(this.id)

Expand All @@ -398,13 +416,12 @@ export class FrameController
await element.loaded
return await this.extractForeignFrameElement(element)
}

console.error(`Response has no matching <turbo-frame id="${id}"> element`)
} catch (error) {
console.error(error)
return new FrameElement()
}

return new FrameElement()
return null
}

private formActionIsVisitable(form: HTMLFormElement, submitter?: HTMLElement) {
Expand Down
1 change: 1 addition & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
7 changes: 7 additions & 0 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,13 @@ export class Session
this.notifyApplicationAfterFrameRender(fetchResponse, frame)
}

async frameMissing(frame: FrameElement, fetchResponse: FetchResponse) {
const responseHTML = await fetchResponse.responseHTML
const { location, redirected, statusCode } = fetchResponse

this.visit(location, { response: { redirected, statusCode, responseHTML } })
}

// Application events

applicationAllowsFollowingLinkToLocation(link: Element, location: URL, ev: MouseEvent) {
Expand Down
1 change: 1 addition & 0 deletions src/tests/fixtures/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,6 @@
"turbo:before-frame-render",
"turbo:frame-load",
"turbo:frame-render",
"turbo:frame-missing",
"turbo:reload"
])
55 changes: 52 additions & 3 deletions src/tests/functional/frame_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down

0 comments on commit 67c0826

Please sign in to comment.