Skip to content

Commit

Permalink
Add render to turbo:before-stream-render event
Browse files Browse the repository at this point in the history
Follow-up to hotwired#479
Related to hotwired/turbo-site#107

As an alternative to exposing an otherwise private and internal
`StreamActions` "class", support custom `<turbo-stream action="...">`
values the same way as custom `<turbo-frame>` rendering or `<body>`
rendering: as part of its `turbo:before-render-stream` event.

To change how Turbo renders the document during page rendering, client
applications declare a `turbo:before-render` event listener that
overrides its `CustomEvent.detail.render` function from its
[default][PageRenderer.renderElement].

Similarly, to change how Turbo renders a frame, client applications
declare a `turbo:before-frame-render` and override its
`CustomEvent.detail.render` function from its [default][].

This commit introduces the `StreamElement.renderElement` function, and
extends the existing `turbo:before-stream-render` event to support the
same pattern with `StreamElement.renderElement` server as the default
`CustomEvent.detail.render` value.

With those changes in place, callers can declare a document-wide event
listener and override based on the value of `StreamElement.action`:

```javascript
const CustomActions = {
  customUpdate: (stream) => { /* ... */ }
  customReplace: (stream) => { /* ... */ }
  // ...
}

document.addEventListener("turbo:before-stream-render", (({ target, detail }) => {
  const defaultRender = detail.render

  detail.render = CustomActions[target.action] || defaultRender
}))
```

[PageRenderer.renderElement]: https://github.com/hotwired/turbo/blob/256418fee0178ee483d82cd9bb579bd5df5a151f/src/core/drive/page_renderer.ts#L7-L13
[FrameRenderer.renderElement]: https://github.com/hotwired/turbo/blob/256418fee0178ee483d82cd9bb579bd5df5a151f/src/core/frames/frame_renderer.ts#L13-L24
  • Loading branch information
seanpdoyle committed Aug 13, 2022
1 parent 256418f commit 33183f4
Show file tree
Hide file tree
Showing 7 changed files with 69 additions and 39 deletions.
2 changes: 0 additions & 2 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ export { TurboFrameMissingEvent } from "./frames/frame_controller"
export { TurboBeforeFetchRequestEvent, TurboBeforeFetchResponseEvent } from "../http/fetch_request"
export { TurboBeforeStreamRenderEvent } from "../elements/stream_element"

export { StreamActions } from "./streams/stream_actions"

/**
* Starts the main session.
* This initialises any necessary observers such as those to monitor
Expand Down
30 changes: 19 additions & 11 deletions src/elements/stream_element.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { StreamActions } from "../core/streams/stream_actions"
import { nextAnimationFrame } from "../util"

export type TurboBeforeStreamRenderEvent = CustomEvent<{ newStream: StreamElement }>
type Render = (currentElement: StreamElement) => Promise<void>

export type TurboBeforeStreamRenderEvent = CustomEvent<{ newStream: StreamElement; render: Render }>

// <turbo-stream action=replace target=id><template>...

Expand All @@ -26,6 +28,10 @@ export type TurboBeforeStreamRenderEvent = CustomEvent<{ newStream: StreamElemen
* </turbo-stream>
*/
export class StreamElement extends HTMLElement {
static async renderElement(newElement: StreamElement): Promise<void> {
await newElement.performAction()
}

async connectedCallback() {
try {
await this.render()
Expand All @@ -40,9 +46,11 @@ export class StreamElement extends HTMLElement {

async render() {
return (this.renderPromise ??= (async () => {
if (this.dispatchEvent(this.beforeRenderEvent)) {
const event = beforeRenderEvent(this, StreamElement.renderElement)

if (this.dispatchEvent(event)) {
await nextAnimationFrame()
this.performAction()
await event.detail.render(this)
}
})())
}
Expand Down Expand Up @@ -149,14 +157,6 @@ export class StreamElement extends HTMLElement {
return (this.outerHTML.match(/<[^>]+>/) ?? [])[0] ?? "<turbo-stream>"
}

private get beforeRenderEvent(): TurboBeforeStreamRenderEvent {
return new CustomEvent("turbo:before-stream-render", {
bubbles: true,
cancelable: true,
detail: { newStream: this },
})
}

private get targetElementsById() {
const element = this.ownerDocument?.getElementById(this.target!)

Expand All @@ -177,3 +177,11 @@ export class StreamElement extends HTMLElement {
}
}
}

function beforeRenderEvent(newStream: StreamElement, render: Render): TurboBeforeStreamRenderEvent {
return new CustomEvent("turbo:before-stream-render", {
bubbles: true,
cancelable: true,
detail: { newStream, render },
})
}
2 changes: 1 addition & 1 deletion src/tests/fixtures/stream.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<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>Create</button>
<button name="id" value="a-turbo-stream">Create</button>
</form>

<form id="append-targets" method="post" action="/__turbo/messages">
Expand Down
12 changes: 10 additions & 2 deletions src/tests/fixtures/test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
(function(eventNames) {
function serializeToChannel(object, returned = {}) {
function serializeToChannel(object, visited = new Set()) {
const returned = {}

for (const key in object) {
const value = object[key]

Expand All @@ -8,7 +10,13 @@
} else if (value instanceof Element) {
returned[key] = value.outerHTML
} else if (typeof value == "object") {
returned[key] = serializeToChannel(value)
visited.add(value)

if (visited.has(value)) {
returned[key] = "skipped to prevent infinitely recursing"
} else {
returned[key] = serializeToChannel(value, visited)
}
} else {
returned[key] = value
}
Expand Down
29 changes: 19 additions & 10 deletions src/tests/functional/stream_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ test("test receiving a stream message", async ({ page }) => {

test("test dispatches a turbo:before-stream-render event", async ({ page }) => {
await page.click("#append-target button")
const { newStream } = await nextEventNamed(page, "turbo:before-stream-render")
await nextEventNamed(page, "turbo:submit-end")
const [[type, detail, target]] = await readEventLogs(page, 1)

assert.ok(newStream.includes(`action="append"`))
assert.ok(newStream.includes(`target="messages"`))
assert.equal(type, "turbo:before-stream-render")
assert.equal(target, "a-turbo-stream")
assert.ok(detail.newStream.includes(`action="append"`))
assert.ok(detail.newStream.includes(`target="messages"`))
})

test("test receiving a stream message with css selector target", async ({ page }) => {
Expand Down Expand Up @@ -73,14 +76,20 @@ test("test overriding with custom StreamActions", async ({ page }) => {
const html = "Rendered with Custom Action"

await page.evaluate((html) => {
window.Turbo.StreamActions.customUpdate = function () {
for (const target of this.targetElements) target.innerHTML = html
const CustomActions: Record<string, any> = {
customUpdate(newStream: { targetElements: HTMLElement[] }) {
for (const target of newStream.targetElements) target.innerHTML = html
},
}
window.Turbo.renderStreamMessage(`
<turbo-stream action="customUpdate" target="messages">
<template></template>
</turbo-stream>
`)

addEventListener("turbo:before-stream-render", (({ target, detail }: CustomEvent) => {
const stream = target as unknown as { action: string }

const defaultRender = detail.render
detail.render = CustomActions[stream.action] || defaultRender
}) as EventListener)

window.Turbo.renderStreamMessage(`<turbo-stream action="customUpdate" target="messages"></turbo-stream>`)
}, html)

assert.equal(await page.textContent("#messages"), html, "evaluates custom StreamAction")
Expand Down
11 changes: 9 additions & 2 deletions src/tests/helpers/page.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { JSHandle, Locator, Page } from "@playwright/test"

type EventLog = [string, any, string | null]
type MutationLog = [string, string | null, string | null]
type Target = string | null

type EventType = string
type EventDetail = any
type EventLog = [EventType, EventDetail, Target]

type MutationAttributeName = string
type MutationAttributeValue = string | null
type MutationLog = [MutationAttributeName, Target, MutationAttributeValue]

export function attributeForSelector(page: Page, selector: string, attributeName: string): Promise<string | null> {
return page.locator(selector).getAttribute(attributeName)
Expand Down
22 changes: 11 additions & 11 deletions src/tests/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ router.get("/delayed_response", (request, response) => {

router.post("/messages", (request, response) => {
const params = { ...request.body, ...request.query }
const { content, status, type, target, targets } = params
const { content, id, status, type, target, targets } = params
if (typeof content == "string") {
receiveMessage(content, target)
receiveMessage(content, id, target)
if (type == "stream" && acceptsStreams(request)) {
response.type("text/vnd.turbo-stream.html; charset=utf-8")
response.send(targets ? renderMessageForTargets(content, targets) : renderMessage(content, target))
response.send(targets ? renderMessageForTargets(content, id, targets) : renderMessage(content, id, target))
} else {
response.sendStatus(parseInt(status || "201", 10))
}
Expand All @@ -90,10 +90,10 @@ router.put("/messages/:id", (request, response) => {
const { content, type } = request.body
const { id } = request.params
if (typeof content == "string") {
receiveMessage(content)
receiveMessage(content, id)
if (type == "stream" && acceptsStreams(request)) {
response.type("text/vnd.turbo-stream.html; charset=utf-8")
response.send(renderMessage(id + ": " + content))
response.send(renderMessage(id + ": " + content, id))
} else {
response.sendStatus(200)
}
Expand All @@ -119,25 +119,25 @@ router.get("/messages", (request, response) => {
streamResponses.add(response)
})

function receiveMessage(content: string, target?: string) {
const data = renderSSEData(renderMessage(content, target))
function receiveMessage(content: string, id: string | null, target?: string) {
const data = renderSSEData(renderMessage(content, id, target))
for (const response of streamResponses) {
intern.log("delivering message to stream", response.socket?.remotePort)
response.write(data)
}
}

function renderMessage(content: string, target = "messages") {
function renderMessage(content: string, id: string | null, target = "messages") {
return `
<turbo-stream action="append" target="${target}"><template>
<turbo-stream id="${id}" action="append" target="${target}"><template>
<div class="message">${escapeHTML(content)}</div>
</template></turbo-stream>
`
}

function renderMessageForTargets(content: string, targets: string) {
function renderMessageForTargets(content: string, id: string | null, targets: string) {
return `
<turbo-stream action="append" targets="${targets}"><template>
<turbo-stream id="${id}" action="append" targets="${targets}"><template>
<div class="message">${escapeHTML(content)}</div>
</template></turbo-stream>
`
Expand Down

0 comments on commit 33183f4

Please sign in to comment.