Skip to content

Commit

Permalink
Support custom rendering in turbo:before{-frame,}-render events (#431)
Browse files Browse the repository at this point in the history
  • Loading branch information
seanpdoyle authored Jul 18, 2022
1 parent 1811189 commit 837e977
Show file tree
Hide file tree
Showing 12 changed files with 154 additions and 61 deletions.
7 changes: 0 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,6 @@ jobs:
- name: Firefox Test
run: yarn test:browser --project=firefox

- name: Upload test results
if: always()
uses: actions/upload-artifact@v2
with:
name: playwright-report
path: playwright-report

- name: Publish dev build
run: .github/scripts/publish-dev-build '${{ secrets.DEV_BUILD_GITHUB_TOKEN }}'
if: ${{ github.event_name == 'push' }}
10 changes: 8 additions & 2 deletions src/core/drive/error_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@ import { PageSnapshot } from "./page_snapshot"
import { Renderer } from "../renderer"

export class ErrorRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) {
const { documentElement, body } = document

documentElement.replaceChild(newElement, body)
}

async render() {
this.replaceHeadAndBody()
this.activateScriptElements()
}

replaceHeadAndBody() {
const { documentElement, head, body } = document
const { documentElement, head } = document
documentElement.replaceChild(this.newHead, head)
documentElement.replaceChild(this.newElement, body)
this.renderElement(this.currentElement, this.newElement)
}

activateScriptElements() {
Expand Down
14 changes: 9 additions & 5 deletions src/core/drive/page_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { PageSnapshot } from "./page_snapshot"
import { ReloadReason } from "../native/browser_adapter"

export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) {
if (document.body && newElement instanceof HTMLBodyElement) {
document.body.replaceWith(newElement)
} else {
document.documentElement.appendChild(newElement)
}
}

get shouldRender() {
return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical
}
Expand Down Expand Up @@ -105,11 +113,7 @@ export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
}

assignNewBody() {
if (document.body && this.newElement instanceof HTMLBodyElement) {
document.body.replaceWith(this.newElement)
} else {
document.documentElement.appendChild(this.newElement)
}
this.renderElement(this.currentElement, this.newElement)
}

get newHeadStylesheetElements() {
Expand Down
12 changes: 7 additions & 5 deletions src/core/drive/page_view.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import { nextEventLoopTick } from "../../util"
import { View, ViewDelegate } from "../view"
import { View, ViewDelegate, ViewRenderOptions } from "../view"
import { ErrorRenderer } from "./error_renderer"
import { PageRenderer } from "./page_renderer"
import { PageSnapshot } from "./page_snapshot"
import { SnapshotCache } from "./snapshot_cache"
import { Visit } from "./visit"

export interface PageViewDelegate extends ViewDelegate<PageSnapshot> {
export type PageViewRenderOptions = ViewRenderOptions<HTMLBodyElement>

export interface PageViewDelegate extends ViewDelegate<HTMLBodyElement, PageSnapshot> {
viewWillCacheSnapshot(): void
}

type PageViewRenderer = PageRenderer | ErrorRenderer

export class PageView extends View<Element, PageSnapshot, PageViewRenderer, PageViewDelegate> {
export class PageView extends View<HTMLBodyElement, PageSnapshot, PageViewRenderer, PageViewDelegate> {
readonly snapshotCache = new SnapshotCache(10)
lastRenderedLocation = new URL(location.href)
forceReloaded = false

renderPage(snapshot: PageSnapshot, isPreview = false, willRender = true, visit?: Visit) {
const renderer = new PageRenderer(this.snapshot, snapshot, isPreview, willRender)
const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender)
if (!renderer.shouldRender) {
this.forceReloaded = true
} else {
Expand All @@ -29,7 +31,7 @@ export class PageView extends View<Element, PageSnapshot, PageViewRenderer, Page

renderError(snapshot: PageSnapshot, visit?: Visit) {
visit?.changeHistory()
const renderer = new ErrorRenderer(this.snapshot, snapshot, false)
const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false)
return this.render(renderer)
}

Expand Down
47 changes: 34 additions & 13 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ 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, dispatch, getAttribute, parseHTMLDocument, markAsBusy } from "../../util"
import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission"
import { Snapshot } from "../snapshot"
import { ViewDelegate } from "../view"
import { ViewDelegate, ViewRenderOptions } from "../view"
import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url"
import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor"
import { FrameView } from "./frame_view"
Expand All @@ -19,6 +19,7 @@ import { FormLinkInterceptor, FormLinkInterceptorDelegate } from "../../observer
import { FrameRenderer } from "./frame_renderer"
import { session } from "../index"
import { isAction } from "../types"
import { TurboBeforeFrameRenderEvent } from "../session"

export class FrameController
implements
Expand All @@ -29,7 +30,7 @@ export class FrameController
FrameElementDelegate,
FormLinkInterceptorDelegate,
LinkInterceptorDelegate,
ViewDelegate<Snapshot<FrameElement>>
ViewDelegate<FrameElement, Snapshot<FrameElement>>
{
readonly element: FrameElement
readonly view: FrameView
Expand All @@ -44,7 +45,7 @@ export class FrameController
private connected = false
private hasBeenLoaded = false
private ignoredAttributes: Set<FrameElementObservedAttribute> = new Set()
private previousContents?: DocumentFragment
private previousFrameElement?: FrameElement

constructor(element: FrameElement) {
this.element = element
Expand Down Expand Up @@ -131,7 +132,14 @@ export class FrameController
if (html) {
const { body } = parseHTMLDocument(html)
const snapshot = new Snapshot(await this.extractForeignFrameElement(body))
const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, false, false)
const renderer = new FrameRenderer(
this,
this.view.snapshot,
snapshot,
FrameRenderer.renderElement,
false,
false
)
if (this.view.renderPromise) await this.view.renderPromise
await this.view.render(renderer)
this.complete = true
Expand Down Expand Up @@ -252,8 +260,22 @@ export class FrameController

// View delegate

allowsImmediateRender(_snapshot: Snapshot, _resume: (value: any) => void) {
return true
allowsImmediateRender({ element: newFrame }: Snapshot<FrameElement>, options: ViewRenderOptions<FrameElement>) {
const event = dispatch<TurboBeforeFrameRenderEvent>("turbo:before-frame-render", {
target: this.element,
detail: { newFrame, ...options },
cancelable: true,
})
const {
defaultPrevented,
detail: { render },
} = event

if (this.view.renderer && render) {
this.view.renderer.renderElement = render
}

return !defaultPrevented
}

viewRenderedSnapshot(_snapshot: Snapshot, _isPreview: boolean) {}
Expand All @@ -265,19 +287,18 @@ export class FrameController
viewInvalidated() {}

// Frame renderer delegate
frameContentsExtracted(fragment: DocumentFragment) {
this.previousContents = fragment
frameExtracted(element: FrameElement) {
this.previousFrameElement = element
}

visitCachedSnapshot = ({ element }: Snapshot) => {
const frame = element.querySelector("#" + this.element.id)

if (frame && this.previousContents) {
frame.innerHTML = ""
frame.append(this.previousContents)
if (frame && this.previousFrameElement) {
frame.replaceChildren(...this.previousFrameElement.children)
}

delete this.previousContents
delete this.previousFrameElement
}

// Private
Expand Down
32 changes: 19 additions & 13 deletions src/core/frames/frame_renderer.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
import { FrameElement } from "../../elements/frame_element"
import { nextAnimationFrame } from "../../util"
import { Renderer } from "../renderer"
import { Render, Renderer } from "../renderer"
import { Snapshot } from "../snapshot"

export interface FrameRendererDelegate {
frameContentsExtracted(fragment: DocumentFragment): void
frameExtracted(element: FrameElement): void
}

export class FrameRenderer extends Renderer<FrameElement> {
private readonly delegate: FrameRendererDelegate

static renderElement(currentElement: FrameElement, newElement: FrameElement) {
const destinationRange = document.createRange()
destinationRange.selectNodeContents(currentElement)
destinationRange.deleteContents()

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

constructor(
delegate: FrameRendererDelegate,
currentSnapshot: Snapshot<FrameElement>,
newSnapshot: Snapshot<FrameElement>,
renderElement: Render<FrameElement>,
isPreview: boolean,
willRender = true
) {
super(currentSnapshot, newSnapshot, isPreview, willRender)
super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender)
this.delegate = delegate
}

Expand All @@ -38,16 +52,8 @@ export class FrameRenderer extends Renderer<FrameElement> {
}

loadFrameElement() {
const destinationRange = document.createRange()
destinationRange.selectNodeContents(this.currentElement)
this.delegate.frameContentsExtracted(destinationRange.extractContents())

const frameElement = this.newElement
const sourceRange = frameElement.ownerDocument?.createRange()
if (sourceRange) {
sourceRange.selectNodeContents(frameElement)
this.currentElement.appendChild(sourceRange.extractContents())
}
this.delegate.frameExtracted(this.newElement.cloneNode(true))
this.renderElement(this.currentElement, this.newElement)
}

scrollFrameIntoView() {
Expand Down
4 changes: 3 additions & 1 deletion src/core/frames/frame_view.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { FrameElement } from "../../elements"
import { Snapshot } from "../snapshot"
import { View } from "../view"
import { View, ViewRenderOptions } from "../view"

export type FrameViewRenderOptions = ViewRenderOptions<FrameElement>

export class FrameView extends View<FrameElement> {
invalidate() {
Expand Down
6 changes: 5 additions & 1 deletion src/core/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,24 @@ type ResolvingFunctions<T = unknown> = {
reject(reason?: any): void
}

export type Render<E> = (newElement: E, currentElement: E) => void

export abstract class Renderer<E extends Element, S extends Snapshot<E> = Snapshot<E>> implements BardoDelegate {
readonly currentSnapshot: S
readonly newSnapshot: S
readonly isPreview: boolean
readonly willRender: boolean
readonly promise: Promise<void>
renderElement: Render<E>
private resolvingFunctions?: ResolvingFunctions<void>
private activeElement: Element | null = null

constructor(currentSnapshot: S, newSnapshot: S, isPreview: boolean, willRender = true) {
constructor(currentSnapshot: S, newSnapshot: S, renderElement: Render<E>, isPreview: boolean, willRender = true) {
this.currentSnapshot = currentSnapshot
this.newSnapshot = newSnapshot
this.isPreview = isPreview
this.willRender = willRender
this.renderElement = renderElement
this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject }))
}

Expand Down
27 changes: 19 additions & 8 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@ import { StreamMessage } from "./streams/stream_message"
import { StreamObserver } from "../observers/stream_observer"
import { Action, Position, StreamSource, isAction } from "./types"
import { clearBusyState, dispatch, markAsBusy } from "../util"
import { PageView, PageViewDelegate } from "./drive/page_view"
import { PageView, PageViewDelegate, PageViewRenderOptions } from "./drive/page_view"
import { Visit, VisitOptions } from "./drive/visit"
import { PageSnapshot } from "./drive/page_snapshot"
import { FrameElement } from "../elements/frame_element"
import { FrameViewRenderOptions } from "./frames/frame_view"
import { FetchResponse } from "../http/fetch_response"
import { Preloader, PreloaderDelegate } from "./drive/preloader"

export type TimingData = unknown
export type TurboBeforeCacheEvent = CustomEvent
export type TurboBeforeRenderEvent = CustomEvent<{ newBody: HTMLBodyElement; resume: (value: any) => void }>
export type TurboBeforeRenderEvent = CustomEvent<{ newBody: HTMLBodyElement } & PageViewRenderOptions>
export type TurboBeforeVisitEvent = CustomEvent<{ url: string }>
export type TurboClickEvent = CustomEvent<{ url: string; originalEvent: MouseEvent }>
export type TurboFrameLoadEvent = CustomEvent
export type TurboBeforeFrameRenderEvent = CustomEvent<{ newFrame: FrameElement } & FrameViewRenderOptions>
export type TurboFrameRenderEvent = CustomEvent<{ fetchResponse: FetchResponse }>
export type TurboLoadEvent = CustomEvent<{ url: string; timing: TimingData }>
export type TurboRenderEvent = CustomEvent
Expand All @@ -46,7 +48,7 @@ export class Session
readonly navigator = new Navigator(this)
readonly history = new History(this)
readonly preloader = new Preloader(this)
readonly view = new PageView(this, document.documentElement)
readonly view = new PageView(this, document.documentElement as HTMLBodyElement)
adapter: Adapter = new BrowserAdapter(this)

readonly pageObserver = new PageObserver(this)
Expand Down Expand Up @@ -259,9 +261,18 @@ export class Session
}
}

allowsImmediateRender({ element }: PageSnapshot, resume: (value: any) => void) {
const event = this.notifyApplicationBeforeRender(element, resume)
return !event.defaultPrevented
allowsImmediateRender({ element }: PageSnapshot, options: PageViewRenderOptions) {
const event = this.notifyApplicationBeforeRender(element, options)
const {
defaultPrevented,
detail: { render },
} = event

if (this.view.renderer && render) {
this.view.renderer.renderElement = render
}

return !defaultPrevented
}

viewRenderedSnapshot(_snapshot: PageSnapshot, _isPreview: boolean) {
Expand Down Expand Up @@ -323,9 +334,9 @@ export class Session
return dispatch<TurboBeforeCacheEvent>("turbo:before-cache")
}

notifyApplicationBeforeRender(newBody: HTMLBodyElement, resume: (value: any) => void) {
notifyApplicationBeforeRender(newBody: HTMLBodyElement, options: PageViewRenderOptions) {
return dispatch<TurboBeforeRenderEvent>("turbo:before-render", {
detail: { newBody, resume },
detail: { newBody, ...options },
cancelable: true,
})
}
Expand Down
Loading

0 comments on commit 837e977

Please sign in to comment.