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

Persist data-turbo-permanent element within frames #71

Merged
merged 8 commits into from
Jan 29, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 27 additions & 81 deletions src/core/drive/page_renderer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { Renderer } from "../renderer"
import { Renderer, focusFirstAutofocusableElement, replaceElementWithElement, renderSnapshotWithPermanentElements } from "../renderer"
import { PageSnapshot } from "./page_snapshot"

export type PermanentElement = Element & { id: string }

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

export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
get shouldRender() {
return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical
Expand All @@ -21,151 +17,101 @@ export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
finishRendering() {
super.finishRendering()
if (this.isPreview) {
this.focusFirstAutofocusableElement()
focusFirstAutofocusableElement(this.newSnapshot)
}
}

get currentHeadSnapshot() {
return this.currentSnapshot.headSnapshot
get newElement() {
return this.newSnapshot.element
}

get newHeadSnapshot() {
return this.newSnapshot.headSnapshot
private get currentHeadSnapshot() {
return this.currentSnapshot.headSnapshot
}

get newElement() {
return this.newSnapshot.element
private get newHeadSnapshot() {
return this.newSnapshot.headSnapshot
}

mergeHead() {
private mergeHead() {
this.copyNewHeadStylesheetElements()
this.copyNewHeadScriptElements()
this.removeCurrentHeadProvisionalElements()
this.copyNewHeadProvisionalElements()
}

replaceBody() {
const placeholders = this.relocateCurrentBodyPermanentElements()
this.activateNewBody()
this.assignNewBody()
this.replacePlaceholderElementsWithClonedPermanentElements(placeholders)
private replaceBody() {
renderSnapshotWithPermanentElements(this.currentSnapshot, this.newSnapshot, () => {
this.activateNewBody()
this.assignNewBody()
})
}

get trackedElementsAreIdentical() {
private get trackedElementsAreIdentical() {
return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature
}

copyNewHeadStylesheetElements() {
private copyNewHeadStylesheetElements() {
for (const element of this.newHeadStylesheetElements) {
document.head.appendChild(element)
}
}

copyNewHeadScriptElements() {
private copyNewHeadScriptElements() {
for (const element of this.newHeadScriptElements) {
document.head.appendChild(this.createScriptElement(element))
}
}

removeCurrentHeadProvisionalElements() {
private removeCurrentHeadProvisionalElements() {
for (const element of this.currentHeadProvisionalElements) {
document.head.removeChild(element)
}
}

copyNewHeadProvisionalElements() {
private copyNewHeadProvisionalElements() {
for (const element of this.newHeadProvisionalElements) {
document.head.appendChild(element)
}
}

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[])
}

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

activateNewBody() {
private activateNewBody() {
document.adoptNode(this.newElement)
this.activateNewBodyScriptElements()
}

activateNewBodyScriptElements() {
private activateNewBodyScriptElements() {
for (const inertScriptElement of this.newBodyScriptElements) {
const activatedScriptElement = this.createScriptElement(inertScriptElement)
replaceElementWithElement(inertScriptElement, activatedScriptElement)
}
}

assignNewBody() {
private assignNewBody() {
if (document.body && this.newElement instanceof HTMLBodyElement) {
replaceElementWithElement(document.body, this.newElement)
} else {
document.documentElement.appendChild(this.newElement)
}
}

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

get newHeadStylesheetElements() {
private get newHeadStylesheetElements() {
return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)
}

get newHeadScriptElements() {
private get newHeadScriptElements() {
return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot)
}

get currentHeadProvisionalElements() {
private get currentHeadProvisionalElements() {
return this.currentHeadSnapshot.provisionalElements
}

get newHeadProvisionalElements() {
private get newHeadProvisionalElements() {
return this.newHeadSnapshot.provisionalElements
}

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

get newBodyScriptElements() {
private get newBodyScriptElements() {
return [ ...this.newElement.querySelectorAll("script") ]
}
}

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"
}
30 changes: 7 additions & 23 deletions src/core/frames/frame_renderer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FrameElement } from "../../elements/frame_element"
import { nextAnimationFrame } from "../../util"
import { Renderer } from "../renderer"
import { Renderer, focusFirstAutofocusableElement, renderSnapshotWithPermanentElements } from "../renderer"

export class FrameRenderer extends Renderer<FrameElement> {
get shouldRender() {
Expand All @@ -9,13 +9,15 @@ export class FrameRenderer extends Renderer<FrameElement> {

async render() {
await nextAnimationFrame()
this.loadFrameElement()
renderSnapshotWithPermanentElements(this.currentSnapshot, this.newSnapshot, () => {
this.loadFrameElement()
})
this.scrollFrameIntoView()
await nextAnimationFrame()
this.focusFirstAutofocusableElement()
focusFirstAutofocusableElement(this.newSnapshot)
}

loadFrameElement() {
private loadFrameElement() {
const destinationRange = document.createRange()
destinationRange.selectNodeContents(this.currentElement)
destinationRange.deleteContents()
Expand All @@ -28,7 +30,7 @@ export class FrameRenderer extends Renderer<FrameElement> {
}
}

scrollFrameIntoView() {
private scrollFrameIntoView() {
if (this.currentElement.autoscroll || this.newElement.autoscroll) {
const element = this.currentElement.firstElementChild
const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end")
Expand All @@ -40,24 +42,6 @@ export class FrameRenderer extends Renderer<FrameElement> {
}
return false
}

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

get id() {
return this.currentElement.id
}

get firstAutofocusableElement() {
const element = this.currentElement.querySelector("[autofocus]")
return element instanceof HTMLElement ? element : null
}
}

function readScrollLogicalPosition(value: string | null, defaultValue: ScrollLogicalPosition): ScrollLogicalPosition {
Expand Down
56 changes: 56 additions & 0 deletions src/core/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ type ResolvingFunctions<T = unknown> = {
reject(reason?: any): void
}

export type PermanentElement = Element & { id: string }

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

export abstract class Renderer<E extends Element, S extends Snapshot<E> = Snapshot<E>> {
readonly currentSnapshot: S
readonly newSnapshot: S
Expand Down Expand Up @@ -62,3 +66,55 @@ function copyElementAttributes(destinationElement: Element, sourceElement: Eleme
destinationElement.setAttribute(name, value)
}
}

function createPlaceholderForPermanentElement(permanentElement: PermanentElement) {
seanpdoyle marked this conversation as resolved.
Show resolved Hide resolved
const element = document.createElement("meta")
element.setAttribute("name", "turbo-permanent-placeholder")
element.setAttribute("content", permanentElement.id)
return { element, permanentElement }
}

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

function relocatePermanentElements(currentSnapshot: Snapshot, newSnapshot: Snapshot) {
return currentSnapshot.getPermanentElementsPresentInSnapshot(newSnapshot).reduce((placeholders, permanentElement) => {
const newElement = 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[])
}

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

export function renderSnapshotWithPermanentElements(currentSnapshot: Snapshot, newSnapshot: Snapshot, render: () => void) {
const placeholders = relocatePermanentElements(currentSnapshot, newSnapshot)
render()
replacePlaceholderElementsWithClonedPermanentElements(placeholders)
}

function elementIsFocusable(element: any): element is { focus: () => void } {
return element && typeof element.focus == "function"
}

export function focusFirstAutofocusableElement(snapshot: Snapshot) {
const element = snapshot.firstAutofocusableElement
if (elementIsFocusable(element)) {
element.focus()
}
}
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