Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Turbo Stream for Instant Review Update

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

When we submit a new review, we update two different parts of the page. First, the review list and review form. And second, the quick stats area up here.

Over in ProductController, in the reviews action, we do this by returning a turbo stream: reviews.stream.html.twig is responsible for updating both spots.

Cool, but remember that the reviews list and review form live inside of a turbo frame. And so, before we started messing around and doing crazy stuff with Turbo Streams, we updated that section simply by returning a redirect to the reviews page on success. The Turbo Frame followed that redirect, grabbed the matching <turbo-frame> from that page and updated it here.

Unfortunately... as soon as we wanted to also update the quick stats area, we had to change completely to rely on turbo streams. The problem is that we can't return a turbo stream and a redirect from the controller.... so we chose to return a stream... which means that the stream needs to update both sections of the page.

Returning a Redirect And Publishing a Stream

Okay. So why are we talking about all of this again? Because now that we have Mercure running, we can, in a sense, return two things from our controller. Check it out: copy this dummy Mercure update code, remove it... and paste it down in the success area.

We're updating the product-reviews stream, which is the stream that we're listening to thanks to our code in _reviews.html.twig. Back in the controller, instead of returning a stream, copy the render line, delete that section, paste inside the update... and fix the formatting. Oh, also change this to renderView(): render() returns a Response object... but all we need is the string from this template. That's what renderView() gives us.

... lines 1 - 20
class ProductController extends AbstractController
{
... lines 23 - 71
/**
* @Route("/product/{id}/reviews", name="app_product_reviews")
*/
public function productReviews(Product $product, CategoryRepository $categoryRepository, Request $request, EntityManagerInterface $entityManager, HubInterface $mercureHub)
{
... lines 77 - 82
if ($request->isMethod('POST')) {
$this->denyAccessUnlessGranted('ROLE_USER');
$reviewForm->handleRequest($request);
if ($reviewForm->isSubmitted() && $reviewForm->isValid()) {
$entityManager->persist($reviewForm->getData());
$entityManager->flush();
$update = new Update(
'product-reviews',
$this->renderView('product/reviews.stream.html.twig', [
'product' => $product,
]),
);
$mercureHub->publish($update);
$this->addFlash('review_success', 'Thanks for your review! I like you!');
return $this->redirectToRoute('app_product_reviews', [
'id' => $product->getId(),
]);
}
}
... lines 107 - 113
}
... lines 115 - 122
}

Thanks to this, our controller will now redirect like it did before... but it will also publish a stream to Mercure along the way.

Let's try it. Refresh the page... and scroll all the way down to the bottom. I want to trigger the weather widget Ajax call just so that we can cleanly see what happens with the network requests when we submit. Clear out the Ajax requests... then add a new review.

Cool! It looks like that worked! Check out the network requests. The first is the POST form submit. This returned a redirect, the frame system followed that redirect, found the frame on the next page, and updated this area. The normal Turbo Frames behavior. Then our stream caused the quick stats area to update... and it also re-updated the reviews area... because, right now, our stream template is still updating both things.

Only Streaming the Quick Stats

So probably we could stop streaming the _reviews.html.twig template... since the turbo-frame is taking care of that part of the page. We only need to focus on updating the quick stats.

<turbo-stream action="update" target="product-quick-stats">
<template>
{{ include('product/_quickStats.html.twig') }}
</template>
</turbo-stream>

Let's try this again. Right now we have 16 reviews. Head down and add the 17th. Ah! Silly validation! Type a bit more and submit. Yes! It still works! The behavior is slightly different than before: it renders a new review form... because that's what's rendered inside the <turbo-frame> on the redirected page. And... up above, the quick stats area did update.

So this is a really pure example of a turbo-stream in action. Inside of our ProductController, we can just redirect like normal, which powers the turbo-frame. Then, the minute that we realize that we need to update a different part of the page - something outside of the frame - we can do that through Mercure.

Updating the Page of Every User

But this is even cooler than it looks at first. In reviews.stream.html.twig, temporarily put back the product-review stream.

<turbo-stream action="update" target="product-quick-stats">
<template>
{{ include('product/_quickStats.html.twig') }}
</template>
</turbo-stream>
<turbo-stream action="replace" target="product-review">
<template>
{{ include('product/_reviews.html.twig') }}
</template>
</turbo-stream>

Back at your browser, copy the URL and open this page in a second tab. Make sure both pages are refreshed. Ok: both show 17 reviews. In the original tab, scroll down and submit review number 18. It does show up here: no surprises.

Now check out the other tab. The quick stats also update here! And, down below, yup! There's review number 18! That's amazing! Sure, I'm sitting on one computer with two tabs open. But if two people - on opposite sides of the planet - were both viewing this page at the same time, the same thing would happen. When we post a new review, everyone's page is updated!

This opens up a new possibility for turbo streams. We already know that we can use streams to update any part of our page, like something that's outside of the frame that we're currently working in. But we can also use streams to update any part of any user's page... so that when a user in Belgium adds a new review, a different user in Japan - who was already on that page - will instantly see it.

Making Update Ids Specific to the Product

But now, in the second tab, navigate to a different product. Back in the first, post review number 19. When I submit, this, of course, works. But check out the second tab. Woh! This product should not have 19 reviews... and all of these reviews are for the other product, not this one! Refresh. Yup! This product has way less reviews. Our stream update is affecting every product page!

And... this makes sense. If you're on a product page - any product page - then you're listening to the product-reviews Mercure topic. When we publish an update, we target the product-quick-stats and product-review elements... both of which exist on every product page!

Fortunately, this is simple to fix. In _reviews.html.twig, we need to make sure that every element that we target with a turbo stream has a dynamic part in it so that it's specific to that product. In the id attribute, change it to product-{{ product.id }}-review.

... lines 1 - 2
<turbo-frame id="product-{{ product.id }}-review">
... lines 4 - 41
</turbo-frame>

In reviews.stream.html.twig, do the same thing so they match. Repeat this for the quick stats, which lives in show.html.twig... here it is. Add {{ product.id }} inside the id. Copy that... and in the stream template, add it here too.

... lines 1 - 32
<div id="product-{{ product.id }}-quick-stats">
... line 34
</div>
... lines 36 - 49

<turbo-stream action="update" target="product-{{ product.id }}-quick-stats">
... lines 2 - 4
</turbo-stream>
... line 6
<turbo-stream action="replace" target="product-{{ product.id }}-review">
... lines 8 - 10
</turbo-stream>

Perfect. If two users are viewing two different products, they will still both be listening to the same Mercure topic. When a review is posted to the first product, the second user will receive the update... but they won't have any elements matching those ids on their page. So, it will do nothing.

Click to post another review. Ah! That killed the frame! Of course: we just changed the id of the frame... so we need to refresh. Post one more review. It shows up here... but it did not affect the other page.

Ok: thanks to the new system, we can simplify our turbo stream even more to deliver exactly the updates we want to every user. That's next.

Leave a comment!

6
Login or Register to join the conversation
Tomasz-I Avatar
Tomasz-I Avatar Tomasz-I | posted 1 year ago

What is better solution taking into account server load (Mercure Hub)? Users subscribe for one topic "product reviews" so that Mercure Pushes information to all of them and Turbo, based on frame ID, decides if do something with it or not - like shown in tutorial. Or the better solution is to have unique topic for each product?

1 Reply

Hey Tomasz-I!

That's an excellent question! But... I don't have a good answer for you. My instinct tells me that having a unique topic for each product would be more efficient... and it almost definitely is. However, I don't know if it makes any practical difference. Regardless of how you name your topics, you will still have the same number of "connections" to Mercure. And, for example, if you look at the hosted Mercure service - https://mercure.rocks/pricing - when you consider pricing, the only 2 factors are "number of concurrent connections" and "number of POST requests per minutes" (so, updates). I'm doing some guessing, but based on this, it seems that the "number of concurrent connections" is the most important thing for performance. And "how many subscribers an update needs to be pushed to" may not be such an important thing.

Again, this is mostly guess work - we don't (yet) have Mercure on production (though I'm guessing we will have it at some point in the next few months).

Cheers!

1 Reply
Tomasz-I Avatar

Exactly. I asked this cause we have a sort of dilemma regarding the way we should handle some things. We use Mercure and Symfony for our Event app and website (mobile app and website for Conference organizers and attendees). For now we use this for Chat but there are multiple cases we are thinking about to use Mercure for communication. For instance - one general topic (not for any private conversation but for general stuff) to push information about new notifications, new surveys, new configuration (new menu item) etc. Now we are sure we will add Turbo for the web part and the idea of pushing new information and configuration to the website client is a great topic to consider. Thank you!

Reply
Joe M. Avatar
Joe M. Avatar Joe M. | Tomasz-I | posted 1 year ago | edited

What it doesn't save in server resources it does save in server bandwidth.

In the example given here each page will only get the network traffic for the review in question, not the network traffic for all reviews.

Maybe that's tiny, maybe it's not -- imagine if amazon pinged out every review update for all the site to every single user currently connected ;-)

I'd used <div {{ turbo_stream_listen('product-'~ product.id ~'-reviews') }}></div> and $mercureHub->publish(new Update('product-'.$product->getId().'-reviews', ...); instead of changing the DIV name for such a usecase.

(Urg, Disqus eats twig templates inline - sorry about that)

1 Reply

Hey Xalior,

Thank you for this tip! I also tweaked your comment and wrapped the code with "pre" & "code" tags, it should be good with this I suppose.

Cheers!

Reply

Hey Tomasz-I!

Yes, this is a great use-case for all of this cool stuff :). The only item I could find specifically about Mercure performance is this issue - https://github.com/dunglas/... - which is not exactly what you were talking about, but somewhat similar. You can see that even dunglas says that he's not sure.

> For instance - one general topic (not for any private conversation but for general stuff) to push information about new notifications, new surveys, new configuration (new menu item) etc

My first thing to try would also be a "general topic" for this. You at least then know that every user has just "one" Mercure connection (not counting private chats) and not multiple at the same time (e.g. one for notifications, one for surveys, one for new config, etc).

Anyways, let me know how it goes!

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "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.18.5
        "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
    }
}

What JavaScript libraries does this tutorial use?

// package.json
{
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.13.13
        "@fortawesome/fontawesome-free": "^5.15.3", // 5.15.3
        "@hotwired/turbo": "^7.0.0-beta.5", // 1.2.6
        "@popperjs/core": "^2.9.1", // 2.9.2
        "@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/Resources/assets", // 0.1.0
        "@symfony/ux-turbo-mercure": "file:vendor/symfony/ux-turbo-mercure/Resources/assets", // 0.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.3.0
        "bootstrap": "^5.0.0-beta2", // 5.0.1
        "chart.js": "^2.9.4",
        "core-js": "^3.0.0", // 3.13.0
        "jquery": "^3.6.0", // 3.6.0
        "react": "^17.0.1", // 17.0.2
        "react-dom": "^17.0.1", // 17.0.2
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-autocomplete": "https://github.com/weaverryan/stimulus-autocomplete#toggle-event-always-dist", // 2.0.0
        "stimulus-use": "^0.24.0-1", // 0.24.0-2
        "sweetalert2": "^11.0.8", // 11.0.12
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.2
        "webpack-notifier": "^1.6.0" // 1.13.0
    }
}
userVoice