Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update the history before rendering promoted frames #618

Merged
merged 23 commits into from Jul 19, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
710c3ad
Add a updateHistory check to visit
manuelpuyol Jun 30, 2022
5d1d47b
make frame change history before rendering
manuelpuyol Jun 30, 2022
c11a9dd
add frame fixture
manuelpuyol Jun 30, 2022
70c21a8
extract history logic to History class
manuelpuyol Jun 30, 2022
5b225a9
maintain the same restorationIdentifier
manuelpuyol Jun 30, 2022
2ccbb21
remove guard that was always true
manuelpuyol Jun 30, 2022
2fa1923
lint
manuelpuyol Jun 30, 2022
3f7d26b
make history receive a method
manuelpuyol Jun 30, 2022
386f4a9
lint
manuelpuyol Jun 30, 2022
c964e7d
reset frame and action at each nav
manuelpuyol Jun 30, 2022
203f219
udpate ids to reflect each link
manuelpuyol Jun 30, 2022
e73df52
extract getVisitAction and fix compiler issues
manuelpuyol Jun 30, 2022
d46717a
lint
manuelpuyol Jun 30, 2022
e99e69e
add before-frame-render event and make the render pausable
manuelpuyol Jun 30, 2022
0deb581
Add test showcasing the URL being updated first
manuelpuyol Jun 30, 2022
16b3bdd
add pause/abort tests for frames
manuelpuyol Jun 30, 2022
486f7ef
update ids to match initial page
manuelpuyol Jul 12, 2022
93395bc
Merge remote-tracking branch 'turbo/main' into frame-history-update
manuelpuyol Jul 15, 2022
eedb214
Merge remote-tracking branch 'turbo/main' into frame-history-update
manuelpuyol Jul 18, 2022
967c725
remove before-frame-render event code since it was introduced in #431
manuelpuyol Jul 18, 2022
9e7a541
yarn.lock
manuelpuyol Jul 18, 2022
944c169
yarn.lock
manuelpuyol Jul 18, 2022
d535b53
Merge branch 'main' into frame-history-update
manuelpuyol Jul 19, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/core/drive/visit.ts
Expand Up @@ -6,7 +6,7 @@ import { getAnchor } from "../url"
import { Snapshot } from "../snapshot"
import { PageSnapshot } from "./page_snapshot"
import { Action } from "../types"
import { uuid } from "../../util"
import { getHistoryMethodForAction, uuid } from "../../util"
import { PageView } from "./page_view"

export interface VisitDelegate {
Expand Down Expand Up @@ -45,13 +45,16 @@ export type VisitOptions = {
response?: VisitResponse
visitCachedSnapshot(snapshot: Snapshot): void
willRender: boolean
updateHistory: boolean
restorationIdentifier?: string
}

const defaultOptions: VisitOptions = {
action: "advance",
historyChanged: false,
visitCachedSnapshot: () => {},
willRender: true,
updateHistory: true,
}

export type VisitResponse = {
Expand All @@ -75,6 +78,7 @@ export class Visit implements FetchRequestDelegate {
readonly timingMetrics: TimingMetrics = {}
readonly visitCachedSnapshot: (snapshot: Snapshot) => void
readonly willRender: boolean
readonly updateHistory: boolean

followedRedirect = false
frame?: number
Expand All @@ -99,10 +103,11 @@ export class Visit implements FetchRequestDelegate {
this.location = location
this.restorationIdentifier = restorationIdentifier || uuid()

const { action, historyChanged, referrer, snapshotHTML, response, visitCachedSnapshot, willRender } = {
...defaultOptions,
...options,
}
const { action, historyChanged, referrer, snapshotHTML, response, visitCachedSnapshot, willRender, updateHistory } =
{
...defaultOptions,
...options,
}
this.action = action
this.historyChanged = historyChanged
this.referrer = referrer
Expand All @@ -111,6 +116,7 @@ export class Visit implements FetchRequestDelegate {
this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action)
this.visitCachedSnapshot = visitCachedSnapshot
this.willRender = willRender
this.updateHistory = updateHistory
this.scrolled = !willRender
}

Expand Down Expand Up @@ -171,9 +177,9 @@ export class Visit implements FetchRequestDelegate {
}

changeHistory() {
if (!this.historyChanged) {
if (!this.historyChanged && this.updateHistory) {
const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action
const method = this.getHistoryMethodForAction(actionForHistory)
const method = getHistoryMethodForAction(actionForHistory)
this.history.update(method, this.location, this.restorationIdentifier)
this.historyChanged = true
}
Expand Down
48 changes: 40 additions & 8 deletions src/core/frames/frame_controller.ts
Expand Up @@ -7,7 +7,15 @@ import {
import { FetchMethod, FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request"
import { FetchResponse } from "../../http/fetch_response"
import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer"
import { clearBusyState, getAttribute, parseHTMLDocument, markAsBusy } from "../../util"
import {
clearBusyState,
getAttribute,
parseHTMLDocument,
markAsBusy,
uuid,
getHistoryMethodForAction,
getVisitAction,
} from "../../util"
import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission"
import { Snapshot } from "../snapshot"
import { ViewDelegate } from "../view"
Expand All @@ -17,7 +25,8 @@ import { FrameView } from "./frame_view"
import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor"
import { FrameRenderer } from "./frame_renderer"
import { session } from "../index"
import { isAction } from "../types"
import { isAction, Action } from "../types"
import { VisitOptions } from "../drive/visit"

export class FrameController
implements
Expand All @@ -41,13 +50,18 @@ export class FrameController
private connected = false
private hasBeenLoaded = false
private ignoredAttributes: Set<FrameElementObservedAttribute> = new Set()
private action: Action | null = null
private frame?: FrameElement
private resolveInterceptionPromise = (_value: any) => {}
readonly restorationIdentifier: string

constructor(element: FrameElement) {
this.element = element
this.view = new FrameView(this, this.element)
this.appearanceObserver = new AppearanceObserver(this, this.element)
this.linkInterceptor = new LinkInterceptor(this, this.element)
this.formInterceptor = new FormInterceptor(this, this.element)
this.restorationIdentifier = uuid()
}

connect() {
Expand Down Expand Up @@ -126,6 +140,12 @@ export class FrameController
const snapshot = new Snapshot(await this.extractForeignFrameElement(body))
const renderer = new FrameRenderer(this.view.snapshot, snapshot, false, false)
if (this.view.renderPromise) await this.view.renderPromise
this.changeHistory()

const renderInterception = new Promise((resolve) => (this.resolveInterceptionPromise = resolve))
const immediateRender = session.frameWillRender(fetchResponse, this.element, this.resolveInterceptionPromise)
if (!immediateRender) await renderInterception

await this.view.render(renderer)
this.complete = true
session.frameRendered(fetchResponse, this.element)
Expand Down Expand Up @@ -277,27 +297,39 @@ export class FrameController
}

private proposeVisitIfNavigatedWithAction(frame: FrameElement, element: Element, submitter?: HTMLElement) {
const action = getAttribute("data-turbo-action", submitter, element, frame)
this.action = getVisitAction(submitter, element, frame)
this.frame = frame

if (isAction(action)) {
if (isAction(this.action)) {
const { visitCachedSnapshot } = new SnapshotSubstitution(frame)
frame.delegate.fetchResponseLoaded = (fetchResponse: FetchResponse) => {
if (frame.src) {
const { statusCode, redirected } = fetchResponse
const responseHTML = frame.ownerDocument.documentElement.outerHTML
const response = { statusCode, redirected, responseHTML }

session.visit(frame.src, {
action,
const options: Partial<VisitOptions> = {
response,
visitCachedSnapshot,
willRender: false,
})
updateHistory: false,
restorationIdentifier: this.restorationIdentifier,
}

if (this.action) options.action = this.action

session.visit(frame.src, options)
}
}
}
}

changeHistory() {
if (this.action && this.frame) {
const method = getHistoryMethodForAction(this.action)
session.history.update(method, expandURL(this.frame.src || ""), this.restorationIdentifier)
}
}

private findFrameElement(element: Element, submitter?: HTMLElement) {
const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target")
return getFrameElementById(id) ?? this.element
Expand Down
2 changes: 1 addition & 1 deletion src/core/native/browser_adapter.ts
Expand Up @@ -24,7 +24,7 @@ export class BrowserAdapter implements Adapter {
}

visitProposedToLocation(location: URL, options?: Partial<VisitOptions>) {
this.navigator.startVisit(location, uuid(), options)
this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options)
}

visitStarted(visit: Visit) {
Expand Down
13 changes: 13 additions & 0 deletions src/core/session.ts
Expand Up @@ -294,6 +294,11 @@ export class Session
this.notifyApplicationAfterFrameRender(fetchResponse, frame)
}

frameWillRender(fetchResponse: FetchResponse, frame: FrameElement, resume: (value: any) => void) {
const event = this.notifyApplicationBeforeFrameRender(fetchResponse, frame, resume)
return !event.defaultPrevented
}

// Application events

applicationAllowsFollowingLinkToLocation(link: Element, location: URL) {
Expand Down Expand Up @@ -369,6 +374,14 @@ export class Session
})
}

notifyApplicationBeforeFrameRender(fetchResponse: FetchResponse, frame: FrameElement, resume: (value: any) => void) {
return dispatch("turbo:before-frame-render", {
detail: { fetchResponse, resume },
target: frame,
cancelable: true,
})
}

// Helpers

formElementDriveEnabled(element?: Element) {
Expand Down
22 changes: 22 additions & 0 deletions src/tests/fixtures/tabs.html
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Tabs</title>
<script src="/dist/turbo.es2017-umd.js"></script>
<script src="/src/tests/fixtures/test.js"></script>
</head>
<body>
<h1>Tabs</h1>

<turbo-frame id="tab-frame" data-turbo-action="advance">
<div>
<a id="tab-1" href="/src/tests/fixtures/tabs.html">Tab 1</a>
<a id="tab-2" href="/src/tests/fixtures/tabs/two.html">Tab 2</a>
<a id="tab-3" href="/src/tests/fixtures/tabs/three.html">Tab 3</a>
</div>

<div id="tab-content">One</div>
</turbo-frame>
</body>
</html>
9 changes: 9 additions & 0 deletions src/tests/fixtures/tabs/three.html
@@ -0,0 +1,9 @@
<turbo-frame id="tab-frame" data-turbo-action="advance">
<div>
<a id="drive_enabled" href="/src/tests/fixtures/tabs.html">Tab 1</a>
<a id="drive_enabled" href="/src/tests/fixtures/tabs/two.html">Tab 2</a>
<a id="drive_enabled" href="/src/tests/fixtures/tabs/three.html">Tab 3</a>
manuelpuyol marked this conversation as resolved.
Show resolved Hide resolved
</div>

<div id="tab-content">Three</div>
</turbo-frame>
9 changes: 9 additions & 0 deletions src/tests/fixtures/tabs/two.html
@@ -0,0 +1,9 @@
<turbo-frame id="tab-frame" data-turbo-action="advance">
<div>
<a id="drive_enabled" href="/src/tests/fixtures/tabs.html">Tab 1</a>
<a id="drive_enabled" href="/src/tests/fixtures/tabs/two.html">Tab 2</a>
<a id="drive_enabled" href="/src/tests/fixtures/tabs/three.html">Tab 3</a>
</div>

<div id="tab-content">Two</div>
</turbo-frame>
1 change: 1 addition & 0 deletions src/tests/fixtures/test.js
Expand Up @@ -29,6 +29,7 @@
"turbo:before-fetch-request",
"turbo:before-fetch-response",
"turbo:visit",
"turbo:before-frame-render",
"turbo:frame-load",
"turbo:frame-render",
"turbo:reload"
Expand Down
23 changes: 23 additions & 0 deletions src/tests/functional/frame_navigation_tests.ts
Expand Up @@ -22,6 +22,29 @@ export class FrameNavigationTests extends TurboDriveTestCase {

await this.nextEventOnTarget("frame", "turbo:frame-load")
}

async "test promoted frame navigation updates the URL before rendering"() {
await this.goToLocation("/src/tests/fixtures/tabs.html")

this.remote.execute(() => {
addEventListener("turbo:before-frame-render", () => {
localStorage.setItem("beforeRenderUrl", window.location.pathname)
localStorage.setItem("beforeRenderContent", document.querySelector("#tab-content")?.textContent || "")
})
})

await this.clickSelector("#tab-2")
await this.nextEventNamed("turbo:before-frame-render")

this.assert.equal(await this.getFromLocalStorage("beforeRenderUrl"), "/src/tests/fixtures/tabs/two.html")
this.assert.equal(await this.getFromLocalStorage("beforeRenderContent"), "One")

await this.nextEventNamed("turbo:frame-render")

const content = await this.querySelector("#tab-content")
this.assert.equal(await this.pathname, "/src/tests/fixtures/tabs/two.html")
this.assert.equal(await content.getVisibleText(), "Two")
}
}

FrameNavigationTests.registerSuite()
48 changes: 48 additions & 0 deletions src/tests/functional/frame_tests.ts
Expand Up @@ -609,6 +609,40 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await this.pathname, "/src/tests/fixtures/page_with_eager_frame.html")
}

async "test pauses and resumes frame renders"() {
this.setupPausableRender()

await this.clickSelector("#hello a")
await this.nextBeat

this.assert.strictEqual(await this.getAlertText(), "Continue rendering?")
await this.acceptAlert()

await this.nextBeat

const frameText = await this.querySelector("#frame h2")
this.assert.equal(await frameText.getVisibleText(), "Frame: Loaded")
}

async "test aborts frame rendering"() {
this.setupPausableRender()

await this.clickSelector("#hello a")
await this.nextBeat

this.assert.strictEqual(await this.getAlertText(), "Continue rendering?")
await this.dismissAlert()

await this.nextBeat
this.assert.strictEqual(await this.getAlertText(), "Rendering aborted")
await this.acceptAlert()

await this.nextBeat

const frameText = await this.querySelector("#frame h2")
this.assert.equal(await frameText.getVisibleText(), "Frames: #frame")
}

async withoutChangingEventListenersCount(callback: () => void) {
const name = "eventListenersAttachedToDocument"
const setup = () => {
Expand Down Expand Up @@ -678,6 +712,20 @@ export class FrameTests extends TurboDriveTestCase {
get frameScriptEvaluationCount(): Promise<number | undefined> {
return this.evaluate(() => window.frameScriptEvaluationCount)
}

setupPausableRender() {
this.remote.execute(() => {
addEventListener("turbo:before-frame-render", (event) => {
event.preventDefault()

if (confirm("Continue rendering?")) {
;(event as CustomEvent).detail.resume()
manuelpuyol marked this conversation as resolved.
Show resolved Hide resolved
} else {
alert("Rendering aborted")
}
})
})
}
}

declare global {
Expand Down
18 changes: 18 additions & 0 deletions src/util.ts
@@ -1,3 +1,5 @@
import { Action, isAction } from "./core/types"

export type DispatchOptions = {
target: EventTarget
cancelable: boolean
Expand Down Expand Up @@ -92,3 +94,19 @@ export function clearBusyState(...elements: Element[]) {
element.removeAttribute("aria-busy")
}
}

export function getHistoryMethodForAction(action: Action) {
switch (action) {
case "replace":
return history.replaceState
case "advance":
case "restore":
return history.pushState
}
}

export function getVisitAction(...elements: (Element | undefined)[]): Action | null {
const action = getAttribute("data-turbo-action", ...elements)

return isAction(action) ? action : null
}