Skip to content

Commit

Permalink
Introduce turbo:frame-missing event (#445)
Browse files Browse the repository at this point in the history
* Introduce `turbo:frame-missing` event

Closes [#432][]
Follow-up to [#94][]
Follow-up to [#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]: #94 (comment)
[#432]: #432
[#94]: #94
[#31]: #31

* re-run CI

* issue a new request for the full page of content

* Add console warning if a full-page visit is triggered as a result of missing matching frame

Co-authored-by: David Heinemeier Hansson <david@hey.com>
  • Loading branch information
seanpdoyle and dhh authored Aug 3, 2022
1 parent 14ae828 commit 9d2a3da
Show file tree
Hide file tree
Showing 5 changed files with 99 additions and 24 deletions.
61 changes: 40 additions & 21 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import { VisitOptions } from "../drive/visit"
import { TurboBeforeFrameRenderEvent, TurboFetchRequestErrorEvent } from "../session"
import { StreamMessage } from "../streams/stream_message"

export type TurboFrameMissingEvent = CustomEvent<{ fetchResponse: FetchResponse }>

export class FrameController
implements
AppearanceObserverDelegate,
Expand Down Expand Up @@ -147,23 +149,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)
Expand Down Expand Up @@ -390,12 +398,24 @@ export class FrameController
}
}

private sessionWillHandleMissingFrame(fetchResponse: FetchResponse) {
this.element.setAttribute("complete", "")

const event = dispatch<TurboFrameMissingEvent>("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<FrameElement> {
async extractForeignFrameElement(container: ParentNode): Promise<FrameElement | null> {
let element
const id = CSS.escape(this.id)

Expand All @@ -410,13 +430,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 @@ -28,6 +28,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
5 changes: 5 additions & 0 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ export class Session
this.notifyApplicationAfterFrameRender(fetchResponse, frame)
}

frameMissing(frame: FrameElement, fetchResponse: FetchResponse): Promise<void> {
console.warn(`Completing full-page visit as matching frame for #${frame.id} was missing from the response`)
return this.visit(fetchResponse.location)
}

// 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 @@ -51,5 +51,6 @@
"turbo:fetch-request-error",
"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 9d2a3da

Please sign in to comment.