diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 649ffd8f2..71fa30909 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' }} diff --git a/src/core/drive/error_renderer.ts b/src/core/drive/error_renderer.ts index 73b0f9652..bd729c2b9 100644 --- a/src/core/drive/error_renderer.ts +++ b/src/core/drive/error_renderer.ts @@ -2,15 +2,21 @@ import { PageSnapshot } from "./page_snapshot" import { Renderer } from "../renderer" export class ErrorRenderer extends Renderer { + 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() { diff --git a/src/core/drive/page_renderer.ts b/src/core/drive/page_renderer.ts index 0d320a033..9a1e1a9fe 100644 --- a/src/core/drive/page_renderer.ts +++ b/src/core/drive/page_renderer.ts @@ -3,6 +3,14 @@ import { PageSnapshot } from "./page_snapshot" import { ReloadReason } from "../native/browser_adapter" export class PageRenderer extends Renderer { + 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 } @@ -105,11 +113,7 @@ export class PageRenderer extends Renderer { } 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() { diff --git a/src/core/drive/page_view.ts b/src/core/drive/page_view.ts index 042ea748e..31cf6a99d 100644 --- a/src/core/drive/page_view.ts +++ b/src/core/drive/page_view.ts @@ -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 { +export type PageViewRenderOptions = ViewRenderOptions + +export interface PageViewDelegate extends ViewDelegate { viewWillCacheSnapshot(): void } type PageViewRenderer = PageRenderer | ErrorRenderer -export class PageView extends View { +export class PageView extends View { 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 { @@ -29,7 +31,7 @@ export class PageView extends View> + ViewDelegate> { readonly element: FrameElement readonly view: FrameView @@ -44,7 +45,7 @@ export class FrameController private connected = false private hasBeenLoaded = false private ignoredAttributes: Set = new Set() - private previousContents?: DocumentFragment + private previousFrameElement?: FrameElement constructor(element: FrameElement) { this.element = element @@ -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 @@ -252,8 +260,22 @@ export class FrameController // View delegate - allowsImmediateRender(_snapshot: Snapshot, _resume: (value: any) => void) { - return true + allowsImmediateRender({ element: newFrame }: Snapshot, options: ViewRenderOptions) { + const event = dispatch("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) {} @@ -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 diff --git a/src/core/frames/frame_renderer.ts b/src/core/frames/frame_renderer.ts index 3e57d6791..cdc855d7a 100644 --- a/src/core/frames/frame_renderer.ts +++ b/src/core/frames/frame_renderer.ts @@ -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 { 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, newSnapshot: Snapshot, + renderElement: Render, isPreview: boolean, willRender = true ) { - super(currentSnapshot, newSnapshot, isPreview, willRender) + super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender) this.delegate = delegate } @@ -38,16 +52,8 @@ export class FrameRenderer extends Renderer { } 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() { diff --git a/src/core/frames/frame_view.ts b/src/core/frames/frame_view.ts index 7dc29dbef..c54dc454f 100644 --- a/src/core/frames/frame_view.ts +++ b/src/core/frames/frame_view.ts @@ -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 export class FrameView extends View { invalidate() { diff --git a/src/core/renderer.ts b/src/core/renderer.ts index f556a6832..a097473d5 100644 --- a/src/core/renderer.ts +++ b/src/core/renderer.ts @@ -8,20 +8,24 @@ type ResolvingFunctions = { reject(reason?: any): void } +export type Render = (newElement: E, currentElement: E) => void + export abstract class Renderer = Snapshot> implements BardoDelegate { readonly currentSnapshot: S readonly newSnapshot: S readonly isPreview: boolean readonly willRender: boolean readonly promise: Promise + renderElement: Render private resolvingFunctions?: ResolvingFunctions private activeElement: Element | null = null - constructor(currentSnapshot: S, newSnapshot: S, isPreview: boolean, willRender = true) { + constructor(currentSnapshot: S, newSnapshot: S, renderElement: Render, 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 })) } diff --git a/src/core/session.ts b/src/core/session.ts index 8817c50ed..7b2bf24e7 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -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 @@ -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) @@ -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) { @@ -323,9 +334,9 @@ export class Session return dispatch("turbo:before-cache") } - notifyApplicationBeforeRender(newBody: HTMLBodyElement, resume: (value: any) => void) { + notifyApplicationBeforeRender(newBody: HTMLBodyElement, options: PageViewRenderOptions) { return dispatch("turbo:before-render", { - detail: { newBody, resume }, + detail: { newBody, ...options }, cancelable: true, }) } diff --git a/src/core/view.ts b/src/core/view.ts index db2774dab..c7f8dfceb 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -1,11 +1,16 @@ import { ReloadReason } from "./native/browser_adapter" -import { Renderer } from "./renderer" +import { Renderer, Render } from "./renderer" import { Snapshot } from "./snapshot" import { Position } from "./types" import { getAnchor } from "./url" -export interface ViewDelegate { - allowsImmediateRender(snapshot: S, resume: (value: any) => void): boolean +export interface ViewRenderOptions { + resume: (value: any) => void + render: Render +} + +export interface ViewDelegate> { + allowsImmediateRender(snapshot: S, options: ViewRenderOptions): boolean preloadOnLoadLinksForView(element: Element): void viewRenderedSnapshot(snapshot: S, isPreview: boolean): void viewInvalidated(reason: ReloadReason): void @@ -15,7 +20,7 @@ export abstract class View< E extends Element, S extends Snapshot = Snapshot, R extends Renderer = Renderer, - D extends ViewDelegate = ViewDelegate + D extends ViewDelegate = ViewDelegate > { readonly delegate: D readonly element: E @@ -85,7 +90,8 @@ export abstract class View< this.prepareToRenderSnapshot(renderer) const renderInterception = new Promise((resolve) => (this.resolveInterceptionPromise = resolve)) - const immediateRender = this.delegate.allowsImmediateRender(snapshot, this.resolveInterceptionPromise) + const options = { resume: this.resolveInterceptionPromise, render: this.renderer.renderElement } + const immediateRender = this.delegate.allowsImmediateRender(snapshot, options) if (!immediateRender) await renderInterception await this.renderSnapshot(renderer) diff --git a/src/tests/fixtures/form.html b/src/tests/fixtures/form.html index 8113b9a58..937b85285 100644 --- a/src/tests/fixtures/form.html +++ b/src/tests/fixtures/form.html @@ -309,7 +309,7 @@

Frame: Form

- Turbo method post to targeted frame + Turbo method post to targeted frame
diff --git a/src/tests/functional/rendering_tests.ts b/src/tests/functional/rendering_tests.ts index f881abbb2..8302da35e 100644 --- a/src/tests/functional/rendering_tests.ts +++ b/src/tests/functional/rendering_tests.ts @@ -62,6 +62,24 @@ test("test reloads when tracked elements change", async ({ page }) => { assert.equal(reason, "tracked_element_mismatch") }) +test("test before-render event supports custom render function", async ({ page }) => { + await page.evaluate(() => + addEventListener("turbo:before-render", (event) => { + const { detail } = event as CustomEvent + const { render } = detail + detail.render = (currentElement: HTMLBodyElement, newElement: HTMLBodyElement) => { + newElement.insertAdjacentHTML("beforeend", `Custom Rendered`) + render(currentElement, newElement) + } + }) + ) + await page.click("#same-origin-link") + await nextBody(page) + + const customRendered = await page.locator("#custom-rendered") + assert.equal(await customRendered.textContent(), "Custom Rendered", "renders with custom function") +}) + test("test wont reload when tracked elements has a nonce", async ({ page }) => { await page.click("#tracked-nonce-tag-link") await nextBody(page) @@ -237,6 +255,26 @@ test("test restores focus during page rendering when transposing an ancestor of assert.ok(await selectorHasFocus(page, "#permanent-descendant-input"), "restores focus after page loads") }) +test("test before-frame-render event supports custom render function within turbo-frames", async ({ page }) => { + const frame = await page.locator("#frame") + await frame.evaluate((frame) => + frame.addEventListener("turbo:before-frame-render", (event) => { + const { detail } = event as CustomEvent + const { render } = detail + detail.render = (currentElement: Element, newElement: Element) => { + newElement.insertAdjacentHTML("beforeend", `Custom Rendered Frame`) + render(currentElement, newElement) + } + }) + ) + + await page.click("#permanent-in-frame-element-link") + await nextBeat() + + const customRendered = await page.locator("#frame #custom-rendered") + assert.equal(await customRendered.textContent(), "Custom Rendered Frame", "renders with custom function") +}) + test("test preserves permanent elements within turbo-frames", async ({ page }) => { assert.equal(await page.textContent("#permanent-in-frame"), "Rendering")