Skip to content

Commit

Permalink
Turbo Streams: Preserve permanent elements
Browse files Browse the repository at this point in the history
Closes #623

Refactor the `Snapshot` implementation to make the permanent element
finding code re-usable outside that module.

Then, introduce the `StreamMessageRenderer` class, and re-use that code.

The `StreamMessageRenderer` class also implements `BardoDelegate`, and
relies on `Bardo.preservingPermanentElements` to manage elements across
their `<turbo-stream>` rendering lifespan.
  • Loading branch information
seanpdoyle committed Aug 17, 2022
1 parent 495de2e commit bb5fb5b
Show file tree
Hide file tree
Showing 4 changed files with 79 additions and 3 deletions.
4 changes: 3 additions & 1 deletion src/core/session.ts
Expand Up @@ -11,6 +11,7 @@ import { Navigator, NavigatorDelegate } from "./drive/navigator"
import { PageObserver, PageObserverDelegate } from "../observers/page_observer"
import { ScrollObserver } from "../observers/scroll_observer"
import { StreamMessage } from "./streams/stream_message"
import { StreamMessageRenderer } from "./streams/stream_message_renderer"
import { StreamObserver } from "../observers/stream_observer"
import { Action, Position, StreamSource, isAction } from "./types"
import { clearBusyState, dispatch, markAsBusy } from "../util"
Expand Down Expand Up @@ -62,6 +63,7 @@ export class Session
readonly streamObserver = new StreamObserver(this)
readonly formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement)
readonly frameRedirector = new FrameRedirector(this, document.documentElement)
readonly streamMessageRenderer = new StreamMessageRenderer()

drive = true
enabled = true
Expand Down Expand Up @@ -129,7 +131,7 @@ export class Session
}

renderStreamMessage(message: StreamMessage | string) {
document.documentElement.appendChild(StreamMessage.wrap(message).fragment)
this.streamMessageRenderer.render(StreamMessage.wrap(message))
}

clearCache() {
Expand Down
12 changes: 10 additions & 2 deletions src/core/snapshot.ts
Expand Up @@ -37,11 +37,11 @@ export class Snapshot<E extends Element = Element> {
}

get permanentElements() {
return [...this.element.querySelectorAll("[id][data-turbo-permanent]")]
return queryPermanentElementsAll(this.element)
}

getPermanentElementById(id: string) {
return this.element.querySelector(`#${id}[data-turbo-permanent]`)
return getPermanentElementById(this.element, id)
}

getPermanentElementMapForSnapshot(snapshot: Snapshot) {
Expand All @@ -59,4 +59,12 @@ export class Snapshot<E extends Element = Element> {
}
}

export function getPermanentElementById(node: ParentNode, id: string) {
return node.querySelector(`#${id}[data-turbo-permanent]`)
}

export function queryPermanentElementsAll(node: ParentNode) {
return node.querySelectorAll("[id][data-turbo-permanent]")
}

export type PermanentElementMap = Record<string, [Element, Element]>
36 changes: 36 additions & 0 deletions src/core/streams/stream_message_renderer.ts
@@ -0,0 +1,36 @@
import { StreamMessage } from "./stream_message"
import { StreamElement } from "../../elements/stream_element"
import { Bardo, BardoDelegate } from "../bardo"
import { PermanentElementMap, getPermanentElementById, queryPermanentElementsAll } from "../snapshot"

export class StreamMessageRenderer implements BardoDelegate {
render({ fragment }: StreamMessage) {
Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () =>
document.documentElement.appendChild(fragment)
)
}

enteringBardo(currentPermanentElement: Element, newPermanentElement: Element) {
newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true))
}

leavingBardo() {}
}

function getPermanentElementMapForFragment(fragment: DocumentFragment): PermanentElementMap {
const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement)
const permanentElementMap: PermanentElementMap = {}
for (const permanentElementInDocument of permanentElementsInDocument) {
const { id } = permanentElementInDocument

for (const streamElement of fragment.querySelectorAll<StreamElement>("turbo-stream")) {
const elementInStream = getPermanentElementById(streamElement.templateElement.content, id)

if (elementInStream) {
permanentElementMap[id] = [permanentElementInDocument, elementInStream]
}
}
}

return permanentElementMap
}
30 changes: 30 additions & 0 deletions src/tests/functional/rendering_tests.ts
Expand Up @@ -395,6 +395,36 @@ test("test preserves permanent element video playback", async ({ page }) => {
assert.equal(timeAfterRender, timeBeforeRender, "element state is preserved")
})

test("test preserves permanent element through Turbo Stream update", async ({ page }) => {
await page.evaluate(() => {
window.Turbo.renderStreamMessage(`
<turbo-stream action="update" target="frame">
<template>
<div id="permanent-in-frame" data-turbo-permanent>Ignored</div>
</template>
</turbo-stream>
`)
})
await nextBeat()

assert.equal(await page.textContent("#permanent-in-frame"), "Rendering")
})

test("test preserves permanent element through Turbo Stream append", async ({ page }) => {
await page.evaluate(() => {
window.Turbo.renderStreamMessage(`
<turbo-stream action="append" target="frame">
<template>
<div id="permanent-in-frame" data-turbo-permanent>Ignored</div>
</template>
</turbo-stream>
`)
})
await nextBeat()

assert.equal(await page.textContent("#permanent-in-frame"), "Rendering")
})

test("test preserves input values", async ({ page }) => {
await page.fill("#text-input", "test")
await page.click("#checkbox-input")
Expand Down

0 comments on commit bb5fb5b

Please sign in to comment.