Skip to content

Commit

Permalink
Persist data-turbo-permanent element within frames
Browse files Browse the repository at this point in the history
Related to #64

Introduce the `FrameRenderer` (as a descendant of the `Renderer`
interface, inspired by the `SnapshotRenderer` implementation) to serve
as an abstraction for the behavior the `FrameController` was previously
responsible for.

Extract `turbo-frame` content extraction from the `FrameController`
class and combine it with code copied from `SnapshotRenderer`
responsible for cloning permanent elements into the new HTML.

It's likely that there is a concept awaiting extraction, or a less
duplicative way of re-using the permanent element extraction logic, but
this is a start.

Note, this commit does not resolve the `<turbo-stream>` element
rendering shortcomings, but might blaze a trail for a `StreamRenderer`
implementation.
  • Loading branch information
seanpdoyle committed Jan 12, 2021
1 parent 21e1769 commit 80466d4
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 64 deletions.
90 changes: 26 additions & 64 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { FetchResponse } from "../../http/fetch_response"
import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer"
import { nextAnimationFrame } from "../../util"
import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission"
import { Snapshot } from "../drive/snapshot"
import { FrameRenderer, RenderDelegate } from "./frame_renderer"
import { Locatable, Location } from "../location"
import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor"
import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor"

export class FrameController implements AppearanceObserverDelegate, FetchRequestDelegate, FormInterceptorDelegate, FormSubmissionDelegate, FrameElementDelegate, LinkInterceptorDelegate {
export class FrameController implements AppearanceObserverDelegate, FetchRequestDelegate, FormInterceptorDelegate, FormSubmissionDelegate, FrameElementDelegate, LinkInterceptorDelegate, RenderDelegate {
readonly element: FrameElement
readonly appearanceObserver: AppearanceObserver
readonly linkInterceptor: LinkInterceptor
Expand Down Expand Up @@ -67,14 +69,10 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
}

async loadResponse(response: FetchResponse): Promise<void> {
const fragment = fragmentFromHTML(await response.responseHTML)
if (fragment) {
const element = await this.extractForeignFrameElement(fragment)
await nextAnimationFrame()
this.loadFrameElement(element)
this.scrollFrameIntoView(element)
await nextAnimationFrame()
this.focusFirstAutofocusableElement()
const html = await response.responseHTML

if (html) {
FrameRenderer.render(this, () => {}, this.element, Snapshot.wrap(html))
}
}

Expand Down Expand Up @@ -169,6 +167,25 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest

}

// Render delegate

viewWillRender(newBody: HTMLBodyElement): void {

}

async viewRendered(newBody: HTMLBodyElement) {
const element = getFrameElementById(this.id)

if (element) {
await nextAnimationFrame()
this.scrollFrameIntoView(element)
}
}

viewInvalidated(): void{

}

// Private

private async visit(url: Locatable) {
Expand All @@ -194,44 +211,6 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
return getFrameElementById(id) ?? this.element
}

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

if (element = activateElement(container.querySelector(`turbo-frame#${id}`))) {
return element
}

if (element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`))) {
await element.loaded
return await this.extractForeignFrameElement(element)
}

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

private loadFrameElement(frameElement: FrameElement) {
const destinationRange = document.createRange()
destinationRange.selectNodeContents(this.element)
destinationRange.deleteContents()

const sourceRange = frameElement.ownerDocument?.createRange()
if (sourceRange) {
sourceRange.selectNodeContents(frameElement)
this.element.appendChild(sourceRange.extractContents())
}
}

private focusFirstAutofocusableElement(): boolean {
const element = this.firstAutofocusableElement
if (element) {
element.focus()
return true
}
return false
}

private scrollFrameIntoView(frame: FrameElement): boolean {
if (this.element.autoscroll || frame.autoscroll) {
const element = this.element.firstElementChild
Expand Down Expand Up @@ -310,20 +289,3 @@ function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLog
return defaultValue
}
}

function fragmentFromHTML(html?: string) {
if (html) {
const foreignDocument = document.implementation.createHTMLDocument()
return foreignDocument.createRange().createContextualFragment(html)
}
}

function activateElement(element: Node | null) {
if (element && element.ownerDocument !== document) {
element = document.importNode(element, true)
}

if (element instanceof FrameElement) {
return element
}
}
120 changes: 120 additions & 0 deletions src/core/frames/frame_renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { FrameElement } from "../../elements/frame_element"
import { RenderCallback, RenderDelegate, Renderer } from "../drive/renderer"
import { Snapshot } from "../drive/snapshot"

export { RenderCallback, RenderDelegate } from "../drive/renderer"

export type PermanentElement = Element & { id: string }

export type Placeholder = { element: Element, permanentElement: PermanentElement }

export class FrameRenderer extends Renderer {
readonly delegate: RenderDelegate
readonly frameElement: FrameElement
readonly currentSnapshot: Snapshot
readonly newSnapshot: Snapshot
readonly newBody: HTMLBodyElement

static render(delegate: RenderDelegate, callback: RenderCallback, frameElement: FrameElement, newSnapshot: Snapshot) {
return new this(delegate, frameElement, newSnapshot).renderView(callback)
}

constructor(delegate: RenderDelegate, frameElement: FrameElement, newSnapshot: Snapshot) {
super()
this.delegate = delegate
this.frameElement = frameElement
this.currentSnapshot = Snapshot.wrap(frameElement.innerHTML)
this.newSnapshot = newSnapshot
this.newBody = newSnapshot.bodyElement
}

async renderView(callback: RenderCallback) {
const newFrameElement = await this.findFrameElement(this.newBody)

if (newFrameElement) {
super.renderView(() => {
const placeholders = this.relocateCurrentBodyPermanentElements()
replaceElementWithElement(this.frameElement, newFrameElement)
this.replacePlaceholderElementsWithClonedPermanentElements(placeholders)
this.focusFirstAutofocusableElement()
callback()
})
} else {
this.invalidateView()
}
}

private async findFrameElement(container: ParentNode): Promise<FrameElement | undefined> {
let element

if (element = container.querySelector(`turbo-frame[id="${this.id}"]`)) {
if (element instanceof FrameElement) {
return element
}
}

if (element = container.querySelector(`turbo-frame[src][recurse~=${this.id}]`)) {
if (element instanceof FrameElement) {
await element.loaded
}
return await this.findFrameElement(element)
}

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

private relocateCurrentBodyPermanentElements() {
return this.currentBodyPermanentElements.reduce((placeholders, permanentElement) => {
const newElement = this.newSnapshot.getPermanentElementById(permanentElement.id)
if (newElement) {
const placeholder = createPlaceholderForPermanentElement(permanentElement)
replaceElementWithElement(permanentElement, placeholder.element)
replaceElementWithElement(newElement, permanentElement)
return [ ...placeholders, placeholder ]
} else {
return placeholders
}
}, [] as Placeholder[])
}

private replacePlaceholderElementsWithClonedPermanentElements(placeholders: Placeholder[]) {
for (const { element, permanentElement } of placeholders) {
const clonedElement = permanentElement.cloneNode(true)
replaceElementWithElement(element, clonedElement)
}
}

private get currentBodyPermanentElements(): PermanentElement[] {
return this.currentSnapshot.getPermanentElementsPresentInSnapshot(this.newSnapshot)
}

private focusFirstAutofocusableElement() {
const element = this.newSnapshot.findFirstAutofocusableElement()
if (elementIsFocusable(element)) {
element.focus()
}
}

private get id(): string {
return CSS.escape(this.frameElement.id)
}
}

function createPlaceholderForPermanentElement(permanentElement: PermanentElement) {
const element = document.createElement("meta")
element.setAttribute("name", "turbo-permanent-placeholder")
element.setAttribute("content", permanentElement.id)
return { element, permanentElement }
}

function replaceElementWithElement(fromElement: Element, toElement: Element) {
const parentElement = fromElement.parentElement
if (parentElement) {
return parentElement.replaceChild(toElement, fromElement)
}
}

function elementIsFocusable(element: any): element is { focus: () => void } {
return element && typeof element.focus == "function"
}
5 changes: 5 additions & 0 deletions src/tests/fixtures/frames/without_layout.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<turbo-frame id="frame">
<h2>Frames: Without Layout</h2>

<div id="permanent-in-frame" data-turbo-permanent>Permanent element</div>
</turbo-frame>
4 changes: 4 additions & 0 deletions src/tests/fixtures/permanent_element.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,9 @@
<h1>Permanent element</h1>
</section>
<div id="permanent" data-turbo-permanent>Permanent element</div>

<turbo-frame id="frame">
<div id="permanent-in-frame" data-turbo-permanent>Permanent element</div>
</turbo-frame>
</body>
</html>
6 changes: 6 additions & 0 deletions src/tests/fixtures/rendering.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ <h1>Rendering</h1>
<p><a id="nonexistent-link" href="/nonexistent">Nonexistent link</a></p>
<p><a id="visit-control-reload-link" href="/src/tests/fixtures/visit_control_reload.html">Visit control: reload</a></p>
<p><a id="permanent-element-link" href="/src/tests/fixtures/permanent_element.html">Permanent element</a></p>
<p><a id="permanent-in-frame-element-link" href="/src/tests/fixtures/permanent_element.html" data-turbo-frame="frame">Permanent element in frame</a></p>
<p><a id="permanent-in-frame-without-layout-element-link" href="/src/tests/fixtures/frames/without_layout.html" data-turbo-frame="frame">Permanent element in frame without layout</a></p>
</section>
<div id="permanent" data-turbo-permanent>Rendering</div>

<turbo-frame id="frame">
<div id="permanent-in-frame" data-turbo-permanent>Rendering</div>
</turbo-frame>
</body>
</html>
20 changes: 20 additions & 0 deletions src/tests/functional/rendering_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@ export class RenderingTests extends TurboDriveTestCase {
this.assert(await permanentElement.equals(await this.permanentElement))
}

async "test preserves permanent elements within turbo-frames"() {
let permanentElement = await this.querySelector("#permanent-in-frame")
this.assert.equal(await permanentElement.getVisibleText(), "Rendering")

await this.clickSelector("#permanent-in-frame-element-link")
await this.nextBeat
permanentElement = await this.querySelector("#permanent-in-frame")
this.assert.equal(await permanentElement.getVisibleText(), "Rendering")
}

async "test preserves permanent elements within turbo-frames rendered without layouts"() {
let permanentElement = await this.querySelector("#permanent-in-frame")
this.assert.equal(await permanentElement.getVisibleText(), "Rendering")

await this.clickSelector("#permanent-in-frame-without-layout-element-link")
await this.nextBeat
permanentElement = await this.querySelector("#permanent-in-frame")
this.assert.equal(await permanentElement.getVisibleText(), "Rendering")
}

async "test before-cache event"() {
this.beforeCache(body => body.innerHTML = "Modified")
this.clickSelector("#same-origin-link")
Expand Down

0 comments on commit 80466d4

Please sign in to comment.