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

Activate <script> in Turbo Streams #660

Merged
merged 1 commit into from
Jul 31, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
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")) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seanpdoyle I think this breaks the current behavior we have with the remove action https://turbo.hotwired.dev/reference/streams#remove because the templateElement requires that the stream element contains a child element <template> which for this specific action is not the case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for raising this issue! I've opened #665 to resolve it.

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