Skip to content

Commit

Permalink
Add render to turbo:before-stream-render event (hotwired#684)
Browse files Browse the repository at this point in the history
* Test harness: Prevent infinite recusion

When serializing `CustomEvent.detail` objects from the browser to the
Playwright test harness, the current `serializeToChannel` implementation
is prone to recurse infinitely if an object nests another object that
refers to the outer object.

To prevent that, this commit tracks which objects have been visited
during the current serialization process, and avoid an infinitely
recursing loop.

Related to that change, this commit also add more descriptive types to
the `EventLog` and `MutationLog` arrays to clarify which positional
elements correspond to their serialized values.

Finally, extend the test server's `<turbo-stream>` creation actions to
support serializing an element with an `[id]` value, so that it can be
serialized from the browser to the test harness, and serve as a
`target.id` value.

* Add `render` to `turbo:before-stream-render` event

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 authored Sep 13, 2022
1 parent cf3d726 commit 9e6e0b9
Show file tree
Hide file tree
Showing 6 changed files with 61 additions and 24 deletions.
16 changes: 12 additions & 4 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 = this.beforeRenderEvent

if (this.dispatchEvent(event)) {
await nextAnimationFrame()
this.performAction()
await event.detail.render(this)
}
})())
}
Expand Down Expand Up @@ -153,7 +161,7 @@ export class StreamElement extends HTMLElement {
return new CustomEvent("turbo:before-stream-render", {
bubbles: true,
cancelable: true,
detail: { newStream: this },
detail: { newStream: this, render: StreamElement.renderElement },
})
}

Expand Down
3 changes: 2 additions & 1 deletion src/tests/fixtures/stream.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html data-skip-event-details="turbo:submit-start turbo:submit-end">
<head>
<meta charset="utf-8">
<title>Turbo Streams</title>
Expand All @@ -10,6 +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">
<input type="hidden" name="id" value="a-turbo-stream">
<button>Create</button>
</form>

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)
if (visited.has(value)) {
returned[key] = "skipped to prevent infinitely recursing"
} else {
visited.add(value)

returned[key] = serializeToChannel(value, visited)
}
} else {
returned[key] = value
}
Expand Down
19 changes: 16 additions & 3 deletions src/tests/functional/stream_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ 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, { newStream }, target]] = await readEventLogs(page, 1)

assert.equal(type, "turbo:before-stream-render")
assert.equal(target, "a-turbo-stream")
assert.ok(newStream.includes(`action="append"`))
assert.ok(newStream.includes(`target="messages"`))
})
Expand Down Expand Up @@ -73,9 +76,19 @@ 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
},
}

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">
<template></template>
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
24 changes: 12 additions & 12 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 @@ -91,7 +91,7 @@ router.get("/stream-response", (request, response) => {
const { content, target, targets } = params
if (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, null, targets) : renderMessage(content, target))
} else {
response.sendStatus(422)
}
Expand All @@ -101,10 +101,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 @@ -130,25 +130,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 9e6e0b9

Please sign in to comment.