diff --git a/src/core/drive/error_renderer.ts b/src/core/drive/error_renderer.ts index bd729c2b9..22deb43e7 100644 --- a/src/core/drive/error_renderer.ts +++ b/src/core/drive/error_renderer.ts @@ -1,5 +1,6 @@ import { PageSnapshot } from "./page_snapshot" import { Renderer } from "../renderer" +import { activateScriptElement } from "../../util" export class ErrorRenderer extends Renderer { static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) { @@ -23,7 +24,7 @@ export class ErrorRenderer extends Renderer { 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) } } @@ -34,6 +35,6 @@ export class ErrorRenderer extends Renderer { } get scriptElements() { - return [...document.documentElement.querySelectorAll("script")] + return document.documentElement.querySelectorAll("script") } } diff --git a/src/core/drive/head_snapshot.ts b/src/core/drive/head_snapshot.ts index e6361aeaf..70fb0ea9b 100644 --- a/src/core/drive/head_snapshot.ts +++ b/src/core/drive/head_snapshot.ts @@ -40,19 +40,19 @@ export class HeadSnapshot extends Snapshot { } getScriptElementsNotInSnapshot(snapshot: HeadSnapshot) { - return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) + return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) } getStylesheetElementsNotInSnapshot(snapshot: HeadSnapshot) { - return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) + return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) } - getElementsMatchingTypeNotInSnapshot(matchedType: ElementType, snapshot: HeadSnapshot) { + getElementsMatchingTypeNotInSnapshot(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[] { diff --git a/src/core/drive/page_renderer.ts b/src/core/drive/page_renderer.ts index ca61c6f1d..08793f232 100644 --- a/src/core/drive/page_renderer.ts +++ b/src/core/drive/page_renderer.ts @@ -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 { static renderElement(currentElement: HTMLBodyElement, newElement: HTMLBodyElement) { @@ -92,7 +92,7 @@ export class PageRenderer extends Renderer { copyNewHeadScriptElements() { for (const element of this.newHeadScriptElements) { - document.head.appendChild(this.createScriptElement(element)) + document.head.appendChild(activateScriptElement(element)) } } @@ -115,7 +115,7 @@ export class PageRenderer extends Renderer { activateNewBodyScriptElements() { for (const inertScriptElement of this.newBodyScriptElements) { - const activatedScriptElement = this.createScriptElement(inertScriptElement) + const activatedScriptElement = activateScriptElement(inertScriptElement) inertScriptElement.replaceWith(activatedScriptElement) } } diff --git a/src/core/frames/frame_renderer.ts b/src/core/frames/frame_renderer.ts index 2243bff02..4f9a17253 100644 --- a/src/core/frames/frame_renderer.ts +++ b/src/core/frames/frame_renderer.ts @@ -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" @@ -72,7 +72,7 @@ export class FrameRenderer extends Renderer { activateScriptElements() { for (const inertScriptElement of this.newScriptElements) { - const activatedScriptElement = this.createScriptElement(inertScriptElement) + const activatedScriptElement = activateScriptElement(inertScriptElement) inertScriptElement.replaceWith(activatedScriptElement) } } diff --git a/src/core/renderer.ts b/src/core/renderer.ts index 53096bc87..91d917aa0 100644 --- a/src/core/renderer.ts +++ b/src/core/renderer.ts @@ -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 = (newElement: E, currentElement: E) => void @@ -46,21 +45,6 @@ export abstract class Renderer = 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) } @@ -105,16 +89,6 @@ export abstract class Renderer = 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 } { diff --git a/src/core/streams/stream_message.ts b/src/core/streams/stream_message.ts index e51c92902..e6ca389a4 100644 --- a/src/core/streams/stream_message.ts +++ b/src/core/streams/stream_message.ts @@ -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("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 } diff --git a/src/observers/stream_observer.ts b/src/observers/stream_observer.ts index 03c1f0193..2b7f38056 100644 --- a/src/observers/stream_observer.ts +++ b/src/observers/stream_observer.ts @@ -70,7 +70,7 @@ export class StreamObserver { } receiveMessageHTML(html: string) { - this.delegate.receivedMessageFromStream(new StreamMessage(html)) + this.delegate.receivedMessageFromStream(StreamMessage.wrap(html)) } } diff --git a/src/tests/fixtures/stream.html b/src/tests/fixtures/stream.html index 9b591a7b5..f7bf22417 100644 --- a/src/tests/fixtures/stream.html +++ b/src/tests/fixtures/stream.html @@ -6,18 +6,17 @@ - -
+ - +
-
+ - +
@@ -28,10 +27,10 @@
First
-
+
Second
-
+
Third
diff --git a/src/tests/functional/stream_tests.ts b/src/tests/functional/stream_tests.ts index 3f08aa234..ca42c81bc 100644 --- a/src/tests/functional/stream_tests.ts +++ b/src/tests/functional/stream_tests.ts @@ -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 + + + `) + ) + + assert.deepEqual(await messages.allTextContents(), ["Hello from script"]) }) test("test overriding with custom StreamActions", async ({ page }) => { @@ -40,32 +57,31 @@ 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", - ` + window.Turbo.renderStreamMessage(` + - ` - ) + + `) }, 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", + `` + ) + }) + 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() @@ -73,9 +89,5 @@ test("test receiving a stream message asynchronously", async ({ page }) => { 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!"]) }) diff --git a/src/util.ts b/src/util.ts index dec113258..ee7db4b7d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -6,6 +6,34 @@ export type DispatchOptions = { 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( eventName: string, { target, cancelable, detail }: Partial> = {}