Skip to content

Commit

Permalink
Activate <script> in Turbo Streams (#660)
Browse files Browse the repository at this point in the history
Closes #527

When a `<turbo-stream>` element connects to the document, "activate"
each `<script>` element by re-creating it as an element owned by the
`document` instance.

To re-use the existing `Renderer.createdScriptElement`, this commit
extracts that logic to the `src/util.ts` module, and renames it to
`activateScriptElement`. Then, we replace each
`this.createdScriptElement` call site with a call to
`activateScriptElement`.

Along with that change, this commit changes the signature to expect an
[HTMLScriptElement][] instance,

Finally, as part of adding test coverage for the new behavior, this
commit re-structures existing tests to be more particular about their
expectations, and to throw failure messages that provide more clarity.

[HTMLScriptElement]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement

Co-authored-by: Shia Labeouf <felixfearest@gmail.com>

Co-authored-by: Shia Labeouf <felixfearest@gmail.com>
  • Loading branch information
seanpdoyle and elShiaLabeouf authored Jul 31, 2022
1 parent 3a18111 commit 97ce0d7
Show file tree
Hide file tree
Showing 10 changed files with 105 additions and 98 deletions.
5 changes: 3 additions & 2 deletions src/core/drive/error_renderer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PageSnapshot } from "./page_snapshot"
import { Renderer } from "../renderer"
import { activateScriptElement } from "../../util"

export class ErrorRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) {
Expand All @@ -23,7 +24,7 @@ export class ErrorRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
for (const replaceableElement of this.scriptElements) {
const parentNode = replaceableElement.parentNode
if (parentNode) {
const element = this.createScriptElement(replaceableElement)
const element = activateScriptElement(replaceableElement)
parentNode.replaceChild(element, replaceableElement)
}
}
Expand All @@ -34,6 +35,6 @@ export class ErrorRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
}

get scriptElements() {
return [...document.documentElement.querySelectorAll("script")]
return document.documentElement.querySelectorAll("script")
}
}
8 changes: 4 additions & 4 deletions src/core/drive/head_snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,19 @@ export class HeadSnapshot extends Snapshot<HTMLHeadElement> {
}

getScriptElementsNotInSnapshot(snapshot: HeadSnapshot) {
return this.getElementsMatchingTypeNotInSnapshot("script", snapshot)
return this.getElementsMatchingTypeNotInSnapshot<HTMLScriptElement>("script", snapshot)
}

getStylesheetElementsNotInSnapshot(snapshot: HeadSnapshot) {
return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot)
return this.getElementsMatchingTypeNotInSnapshot<HTMLLinkElement>("stylesheet", snapshot)
}

getElementsMatchingTypeNotInSnapshot(matchedType: ElementType, snapshot: HeadSnapshot) {
getElementsMatchingTypeNotInSnapshot<T extends Element>(matchedType: ElementType, snapshot: HeadSnapshot): T[] {
return Object.keys(this.detailsByOuterHTML)
.filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML))
.map((outerHTML) => this.detailsByOuterHTML[outerHTML])
.filter(({ type }) => type == matchedType)
.map(({ elements: [element] }) => element)
.map(({ elements: [element] }) => element) as T[]
}

get provisionalElements(): Element[] {
Expand Down
6 changes: 3 additions & 3 deletions src/core/drive/page_renderer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Renderer } from "../renderer"
import { PageSnapshot } from "./page_snapshot"
import { ReloadReason } from "../native/browser_adapter"
import { waitForLoad } from "../../util"
import { activateScriptElement, waitForLoad } from "../../util"

export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) {
Expand Down Expand Up @@ -92,7 +92,7 @@ export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {

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

Expand All @@ -115,7 +115,7 @@ export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {

activateNewBodyScriptElements() {
for (const inertScriptElement of this.newBodyScriptElements) {
const activatedScriptElement = this.createScriptElement(inertScriptElement)
const activatedScriptElement = activateScriptElement(inertScriptElement)
inertScriptElement.replaceWith(activatedScriptElement)
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/frames/frame_renderer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FrameElement } from "../../elements/frame_element"
import { nextAnimationFrame } from "../../util"
import { activateScriptElement, nextAnimationFrame } from "../../util"
import { Render, Renderer } from "../renderer"
import { Snapshot } from "../snapshot"

Expand Down Expand Up @@ -72,7 +72,7 @@ export class FrameRenderer extends Renderer<FrameElement> {

activateScriptElements() {
for (const inertScriptElement of this.newScriptElements) {
const activatedScriptElement = this.createScriptElement(inertScriptElement)
const activatedScriptElement = activateScriptElement(inertScriptElement)
inertScriptElement.replaceWith(activatedScriptElement)
}
}
Expand Down
26 changes: 0 additions & 26 deletions src/core/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { ResolvingFunctions } from "./types"
import { Bardo, BardoDelegate } from "./bardo"
import { Snapshot } from "./snapshot"
import { ReloadReason } from "./native/browser_adapter"
import { getMetaContent } from "../util"

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

Expand Down Expand Up @@ -46,21 +45,6 @@ export abstract class Renderer<E extends Element, S extends Snapshot<E> = Snapsh
}
}

createScriptElement(element: Element) {
if (element.getAttribute("data-turbo-eval") == "false") {
return element
} else {
const createdScriptElement = document.createElement("script")
if (this.cspNonce) {
createdScriptElement.nonce = this.cspNonce
}
createdScriptElement.textContent = element.textContent
createdScriptElement.async = false
copyElementAttributes(createdScriptElement, element)
return createdScriptElement
}
}

preservingPermanentElements(callback: () => void) {
Bardo.preservingPermanentElements(this, this.permanentElementMap, callback)
}
Expand Down Expand Up @@ -105,16 +89,6 @@ export abstract class Renderer<E extends Element, S extends Snapshot<E> = Snapsh
get permanentElementMap() {
return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
}

get cspNonce() {
return getMetaContent("csp-nonce")
}
}

function copyElementAttributes(destinationElement: Element, sourceElement: Element) {
for (const { name, value } of [...sourceElement.attributes]) {
destinationElement.setAttribute(name, value)
}
}

function elementIsFocusable(element: any): element is { focus: () => void } {
Expand Down
35 changes: 14 additions & 21 deletions src/core/streams/stream_message.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,33 @@
import { StreamElement } from "../../elements/stream_element"
import { activateScriptElement, createDocumentFragment } from "../../util"

export class StreamMessage {
static readonly contentType = "text/vnd.turbo-stream.html"
readonly templateElement = document.createElement("template")
readonly fragment: DocumentFragment

static wrap(message: StreamMessage | string) {
if (typeof message == "string") {
return new this(message)
return new this(createDocumentFragment(message))
} else {
return message
}
}

constructor(html: string) {
this.templateElement.innerHTML = html
constructor(fragment: DocumentFragment) {
this.fragment = importStreamElements(fragment)
}
}

function importStreamElements(fragment: DocumentFragment): DocumentFragment {
for (const element of fragment.querySelectorAll<StreamElement>("turbo-stream")) {
const streamElement = document.importNode(element, true)

get fragment() {
const fragment = document.createDocumentFragment()
for (const element of this.foreignElements) {
fragment.appendChild(document.importNode(element, true))
for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) {
inertScriptElement.replaceWith(activateScriptElement(inertScriptElement))
}
return fragment
}

get foreignElements() {
return this.templateChildren.reduce((streamElements, child) => {
if (child.tagName.toLowerCase() == "turbo-stream") {
return [...streamElements, child as StreamElement]
} else {
return streamElements
}
}, [] as StreamElement[])
element.replaceWith(streamElement)
}

get templateChildren() {
return Array.from(this.templateElement.content.children)
}
return fragment
}
2 changes: 1 addition & 1 deletion src/observers/stream_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class StreamObserver {
}

receiveMessageHTML(html: string) {
this.delegate.receivedMessageFromStream(new StreamMessage(html))
this.delegate.receivedMessageFromStream(StreamMessage.wrap(html))
}
}

Expand Down
13 changes: 6 additions & 7 deletions src/tests/fixtures/stream.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,17 @@
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>
</head>
<body>
<turbo-stream-source id="stream-source" src="/__turbo/messages"></turbo-stream-source>
<form id="create" method="post" action="/__turbo/messages">
<form id="append-target" method="post" action="/__turbo/messages">
<input type="hidden" name="content" value="Hello world!">
<input type="hidden" name="type" value="stream">
<button type="submit">Create</button>
<button>Create</button>
</form>

<form id="replace" method="post" action="/__turbo/messages">
<form id="append-targets" method="post" action="/__turbo/messages">
<input type="hidden" name="content" value="Hello CSS!">
<input type="hidden" name="targets" value=".messages">
<input type="hidden" name="type" value="stream">
<button type="submit">Replace</button>
<button>Replace</button>
</form>

<form id="async" method="post" action="/__turbo/messages">
Expand All @@ -28,10 +27,10 @@
<div id="messages">
<div class="message">First</div>
</div>
<div class="messages" id="message_2">
<div id="messages_2" class="messages">
<div class="message">Second</div>
</div>
<div class="messages">
<div id="messages_3" class="messages">
<div class="message">Third</div>
</div>
</body>
Expand Down
76 changes: 44 additions & 32 deletions src/tests/functional/stream_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,47 @@ test.beforeEach(async ({ page }) => {
})

test("test receiving a stream message", async ({ page }) => {
const selector = "#messages div.message:last-child"
const messages = await page.locator("#messages .message")

assert.equal(await page.textContent(selector), "First")
assert.deepEqual(await messages.allTextContents(), ["First"])

await page.click("#create [type=submit]")
await page.click("#append-target button")
await nextBeat()

assert.equal(await page.textContent(selector), "Hello world!")
assert.deepEqual(await messages.allTextContents(), ["First", "Hello world!"])
})

test("test receiving a stream message with css selector target", async ({ page }) => {
let element
const selector = ".messages div.message:last-child"
const messages2 = await page.locator("#messages_2 .message")
const messages3 = await page.locator("#messages_3 .message")

element = await page.locator(selector).allTextContents()
assert.equal(await element[0], "Second")
assert.equal(await element[1], "Third")
assert.deepEqual(await messages2.allTextContents(), ["Second"])
assert.deepEqual(await messages3.allTextContents(), ["Third"])

await page.click("#replace [type=submit]")
await page.click("#append-targets button")
await nextBeat()

element = await page.locator(selector).allTextContents()
assert.equal(await element[0], "Hello CSS!")
assert.equal(await element[1], "Hello CSS!")
assert.deepEqual(await messages2.allTextContents(), ["Second", "Hello CSS!"])
assert.deepEqual(await messages3.allTextContents(), ["Third", "Hello CSS!"])
})

test("test receiving a message with a <script> element", async ({ page }) => {
const messages = await page.locator("#messages .message")

await page.evaluate(() =>
window.Turbo.renderStreamMessage(`
<turbo-stream action="append" target="messages">
<template>
<script>
const messages = document.querySelector("#messages .message")
messages.textContent = "Hello from script"
</script>
</template>
</turbo-stream>
`)
)

assert.deepEqual(await messages.allTextContents(), ["Hello from script"])
})

test("test overriding with custom StreamActions", async ({ page }) => {
Expand All @@ -40,42 +57,37 @@ test("test overriding with custom StreamActions", async ({ page }) => {
window.Turbo.StreamActions.customUpdate = function () {
for (const target of this.targetElements) target.innerHTML = html
}
document.body.insertAdjacentHTML(
"afterbegin",
`<turbo-stream action="customUpdate" target="messages">
window.Turbo.renderStreamMessage(`
<turbo-stream action="customUpdate" target="messages">
<template></template>
</turbo-stream>`
)
</turbo-stream>
`)
}, html)

assert.equal(await page.textContent("#messages"), html, "evaluates custom StreamAction")
})

test("test receiving a stream message asynchronously", async ({ page }) => {
let messages = await page.locator("#messages > *").allTextContents()
await page.evaluate(() => {
document.body.insertAdjacentHTML(
"afterbegin",
`<turbo-stream-source id="stream-source" src="/__turbo/messages"></turbo-stream-source>`
)
})
const messages = await page.locator("#messages .message")

assert.ok(messages[0])
assert.notOk(messages[1], "receives streams when connected")
assert.notOk(messages[2], "receives streams when connected")
assert.deepEqual(await messages.allTextContents(), ["First"])

await page.click("#async button")
await nextBeat()

messages = await page.locator("#messages > *").allTextContents()

assert.ok(messages[0])
assert.ok(messages[1], "receives streams when connected")
assert.notOk(messages[2], "receives streams when connected")
assert.deepEqual(await messages.allTextContents(), ["First", "Hello world!"])

await page.evaluate(() => document.getElementById("stream-source")?.remove())
await nextBeat()

await page.click("#async button")
await nextBeat()

messages = await page.locator("#messages > *").allTextContents()

assert.ok(messages[0])
assert.ok(messages[1], "receives streams when connected")
assert.notOk(messages[2], "does not receive streams when disconnected")
assert.deepEqual(await messages.allTextContents(), ["First", "Hello world!"])
})
28 changes: 28 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ export type DispatchOptions<T extends CustomEvent> = {
detail: T["detail"]
}

export function activateScriptElement(element: HTMLScriptElement) {
if (element.getAttribute("data-turbo-eval") == "false") {
return element
} else {
const createdScriptElement = document.createElement("script")
const cspNonce = getMetaContent("csp-nonce")
if (cspNonce) {
createdScriptElement.nonce = cspNonce
}
createdScriptElement.textContent = element.textContent
createdScriptElement.async = false
copyElementAttributes(createdScriptElement, element)
return createdScriptElement
}
}

function copyElementAttributes(destinationElement: Element, sourceElement: Element) {
for (const { name, value } of sourceElement.attributes) {
destinationElement.setAttribute(name, value)
}
}

export function createDocumentFragment(html: string): DocumentFragment {
const template = document.createElement("template")
template.innerHTML = html
return template.content
}

export function dispatch<T extends CustomEvent>(
eventName: string,
{ target, cancelable, detail }: Partial<DispatchOptions<T>> = {}
Expand Down

0 comments on commit 97ce0d7

Please sign in to comment.