Processing Streams by Hand for Fun & Profit

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

As we've learned, each time Turbo makes an Ajax call, it listens to see if the response has a content type of text/vnd.turbo-stream.html. If it does, then the HTML is passed to the Turbo Stream system... and it works its magic. But in theory, you could grab some Turbo Stream HTML from anywhere and tell the Stream system to process it. And... it's kind of fun!

Head to the homepage. This counter area is fueled by a Stimulus controller: the one at assets/controllers/counter_controller.js. It's pretty simple: click it and then it increments a variable and updates the text with the new number. In addition to doing this, I want to invent a Turbo Stream that adds a flash message to the top of the page.

Adding the Stream Target to the Page

First, we need to be able to target the flash area so that we can put stuff into it. In templates/base.html.twig, find the section - I'll search for flash - and surround it: a <div> with id set to, how about, flash-container. Pop the closing tag on the other side.

<!DOCTYPE html>
<html lang="en-US">
<head>
... lines 4 - 13
</head>
<body>
... lines 16 - 69
<div id="flash-container">
{% for flash in app.session.flashBag.get('success') %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ flash }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">
<span aria-hidden="true"></span>
</button>
</div>
{% endfor %}
</div>
... lines 81 - 100
</body>
</html>

Manually Creating a Stream

Back in counter_controller.js, right after we update the count on the page, let's add a new variable: const streamMessage set to some ticks so we can easily create a multi-line string. Inside, we're literally going to invent a new <turbo-stream> with action="update" and target="flash-container". Add the template element and, inside of that, an alert success div:

thanks for clicking ${this.count} times.

This variable is a plain, boring string... but a string that has the <turbo-stream> format.

So... could we tell the Turbo Stream system to read this and follow its instructions? And if so, how?

At the top of this file, we're already importing the visit function from @hotwired/turbo. This library exports a bunch of other things, including a function called renderStreamMessage.

Copy that. Down below, it's as simple as this: renderStreamMessage() passing streamMessage.

... line 1
import { visit, renderStreamMessage } from '@hotwired/turbo';
... line 3
export default class extends Controller {
... lines 5 - 7
increment() {
this.count++;
this.countTarget.innerText = this.count;
const streamMessage = `
<turbo-stream action="update" target="flash-container">
<template>
<div class="alert alert-success">
Thanks for clicking ${this.count} times!
</div>
</template>
</turbo-stream>
`;
renderStreamMessage(streamMessage);
if (this.count === 10) {
visit('/you-won');
}
}
}

Done! Let's try this thing. Head back over, refresh and click. Oh! That's so cool. We now have a dead-simple way to mutate different elements on your page from JavaScript. And more importantly, this shows off the fact that stream handling is a standalone system inside of Turbo. And so, in theory, we could get this stream HTML from any source, not just from an Ajax call. That will be important in the next chapter.

Removing an Element from the Snapshot Cache

Go back to the page and click this 10 times. Woo! The Stimulus controller navigates us to the winning page! Click back to the homepage... but watch closely. Did you see the flash message? It was there for just a moment and then disappeared.

That is totally unrelated to streams. This would happen with any flash message in Turbo Drive, thanks to its preview system. But even though this has nothing to do with streams, while we're here, let's fix it... and learn something new along the way!

One solution to this would be to go into assets/turbo/turbo-helper.js and remove any flash messages before the snapshot is taken. We already have logic for that: we listen to turbo:before-cache and clean up several elements.

But starting in Turbo 7 Beta 8, there's a new attribute that you can add to any HTML element that you do not want to include in your snapshot. If you think about it, we never want a flash message to be in a snapshot: we want the flash message to show once... but not be there if we navigate away and then back again.

So, in base.html.twig, it's really simple: on flash-container - which will contain all flash messages - add a new data-turbo-cache="false".

<!DOCTYPE html>
<html lang="en-US">
<head>
... lines 4 - 13
</head>
<body>
... lines 16 - 69
<div id="flash-container" data-turbo-cache="false">
{% for flash in app.session.flashBag.get('success') %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ flash }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">
<span aria-hidden="true"></span>
</button>
</div>
{% endfor %}
</div>
... lines 81 - 100
</body>
</html>

That's it! Thanks to this, the entire element - and anything inside - will not be included in the snapshot. Check it out: refresh the homepage... click 10 times and go back. Beautiful! No flash message.

Next: we know we can return Turbo Streams as the response from a controller. That's what we've been doing so far in the reviews action. But there's also another - more powerful - way to send streams to your users.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.13.3
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/ux-turbo-mercure": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0
    }
}