[Feature Idea] [Turbo Streams] [ux-turbo] Render view as Turbo Stream
Preface
Before I dive in, to give some context I am dealing with the issue described below, and have implemented a solution, but I am not sure if this is the best approach, and I'm not even sure that I'm not misusing something in a way that creates the issue. I am working on a project that is transitioning to using Symfony UX. We transitioned all the JS to Stimulus, and are making the application progressively more SPA-like with Turbo. A big part of that is using streams.
The problem
1) Explosion of .turbo.stream.html.twig files
Often times I need to update a small piece of the page and find myself taking that piece of twig, splitting it as a partial and then including that in the original twig, and in a new twig with .turbo.stream in the name that looks like this:
<turbo-stream action="replace" target="smallPiece">
<template>
{{ include('_smallPiece.html.twig', parameters) }}
</template>
</turbo-stream>
Soon the codebase became littered with those tiny turbo-stream wrapper files. However there are also a few instances where I have more than one of these streams in the same twig.
2) Setting the request format when responding with a Turbo Stream
This is a minor complaint, but ties in with the proposal very well. My gripe here is that whenever I am converting a response to a turbo stream I need to remember to DI the Request and do $request->setRequestFormat(TurboBundle::STREAM_FORMAT);
This feels like a chore and adds visual clutter. I would much prefer to have a separate rendering method that would take care of it.
public function renderSmallPiece(Request $request): Response
{
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->render('_smallPiece.turbo.stream.html.twig', ['param' => 'value']);
}
The solution
I have a working solution, that may soon become the standard in my organisation, but I figured it's a good idea to pitch it here and see if there is a better approach, or get confirmation that I'm on the right track. If there is interest I will polish it up and submit a PR soon.
The proposed solution is a renderAsStream method as described above that is similar to the render method but takes two extra parameters - action and target. It wraps the view with the turbo-stream and template tags, and then sets the response's 'Content-Type' to TurboBundle::STREAM_MEDIA_TYPE, also eliminating problem 2).
Considering the case for multiple streams in the same response I also created a renderTurboStreams method that takes a collection of streams (defined by target, action, view, parameters) and renders them all the same way as above, but in one response.
The example above would become:
public function renderSmallPiece(): Response
{
return $this->renderAsStream('smallPiece', TurboAction::REPLACE, '_smallPiece.html.twig', ['param' => 'value']);
}
My current implementation depends on the abstractController from the framework bundle. It looks something like this:
protected function renderTurboStreams(TurboStreamCollection $streams): Response
{
$response = $this->render('TurboResponse/turboStreamsView.html.twig', ['streams' => $streams->all(),]);
$response->headers->set('Content-Type', TurboBundle::STREAM_MEDIA_TYPE);
return $response;
}
The $streams parameter is an array of TurboStream objects - readonly with target, action, view, parameters The turboStreamsView iterates over them and wraps each one with the turbo-stream and template tags with the corresponding action and target.
NB
This was done to prevent code duplication from the render method. However it means the new functionality must either be added to the AbstractController, or as my current solution it lives in TurboAbstactController which extends the frameworkBundle's AbstractController. Since the frameworkBundle is decoupled from the UX stuff I guess this is more plausible.
In order to use this one would only need to change their controllers to extend the new TurboAbstractController
The renderAsStream method is just a shorthand for when you only need one stream. It takes (target, action, view, parameters), creates a TurboStream object from the parameters, adds it to a collection and invokes the renderTurboStreamsMethod:
protected function renderAsStream(string $target, TurboAction $action, $view, array $parameters = []): Response
{
$streams = (new TurboStreamCollection())->add($target, $action, $view, $parameters);
return $this->renderTurboStreams($streams);
}
It only serves the purpose of reducing boilerplate
The example from above:
public function renderSmallPiece(): Response
{
return $this->renderAsStream('smallPiece', TurboAction::REPLACE, '_smallPiece.html.twig', ['param' => 'value']);
}
can use the other method instead:
public function renderSmallPiece(): Response
{
return $this->renderTurboStreams(
(new TurboStreamCollection())
->add('smallPiece', TurboAction::REPLACE, '_smallPiece.html.twig', ['param' => 'value'])
);
}
and in the case of needing more than one stream:
public function renderSmallPiece(): Response
{
return $this->renderTurboStreams(
(new TurboStreamCollection())
->add('smallPiece', TurboAction::REPLACE, '_smallPiece.html.twig', ['param' => 'value'])
->add('otherPiece', TurboAction::REPLACE, '_otherPiece.html.twig', ['param' => 'value'])
);
}
I am curious if others have the same issue and if so do you find this useful. If there is a fundamental flaw in my approach that I've overlooked please let me know. If I am overthinking this and missing an easy solution please point me in the right direction. If this is the right direction, but there is a better way I or some tweaks are needed I would welcome community feedback.
If there is interest in this I can release it as a bundle soon, and in the ideal scenario it will become an official part of the Symfony UX initiative
Hi @DRaichev !
Thank you for sharing your experience with UX Turbo and suggesting new features
However it means the new functionality must either be added to the AbstractController, As you say just after, this is not something possible.
TurboAbstactController which extends the frameworkBundle's AbstractController.
I'm not sure this is something we would encourage. What would happen if we also did a StimulusAbstractController ? And user needed both ?
And i know a lot of people that do not use the AbstractController
If i read it right, on your app you solved your need with a custom TurboAbstractController. But as every app would have its own requirements, i don't think we should offer it in the Turbo Bundle.
Often times I need to update a small piece of the page and find myself taking that piece of twig, splitting it as a partial and then including that in the original twig, and in a new twig with .turbo.stream in the name that looks like this:
You could have a unique template with the stream tags, and inside render just the block you need from the original template
// App/Controller/YourController.php
// ..
public function yourAction(Request $request): Response
{
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->render('turbo.stream.html.twig', [
'template' => 'action_template.html.twig', // the full template
'block' => 'small_piece', // the block name inside the template that contains the "small piece"
'parameters' => ...
]);
}
And the template would look like
{# templates/turbo.stream.html.twig #}
<turbo-stream action="replace" target="smallPiece">
<template>
{% if block|default %}
{{ block(template, block, parameters) }}
{% else %}
{{ include(template, parameters) }}
{% endif %}
</template>
</turbo-stream>
See the twig documentation: https://twig.symfony.com/doc/3.x/functions/block.html
Or you could do the opposite, render just the block you need from the controller with the renderBlock method, and then wrap the result with the turbo tags before sending the response
// App/Controller/YourController.php
// ..
public function yourAction(Request $request): Response
{
// ...
$content = $this->renderBlock('action_template.html.twig', 'small_piece', $parameters)
return new Response('<turbo-strem ...>'.$content.'</turbostrem>');
}
If you find a solution that works for you, you can always put it in a trait and use it in your controllers without needing to extends the AbstractController.
And maybe this trait could be added to the Bundle ?
Hi, @smnandre Thanks for the feedback.
Yes, the render block and add tags does seem nice, but becomes clunky with multiple streams.
I really like the trait idea, and it was actually what I originally wanted to do, but I initially dropped it because I can't really guarantee the render methods exist.
What I mean is the renderTurboStreams method needs to use $this->render, but the trait then depends on $this->render existing and doing what is intended, with no way to guarantee it.
I have since found a solution, and I should have updated the issue with this information, but I thought there was no interest in it since there was no activity.
The way I've solved it is by including the required method(s) as abstract methods in the trait, thus making something I'd describe as a "abstract trait". So when you use the trait you get to use the code in it, but you must implement the abstract methods on which it depends, and since the AbstractController implements them you are all set when you use the trait in a symfony controller. Basically the trait requires that you implement an "interface" when you use it. I haven't really seen this before, but it doesn't seem like an anti-pattern.
Here's what I mean in code:
trait TurboResponseTrait
{
abstract protected function render(string $view, array $parameters = [], ?Response $response = null): Response;
protected function renderAsStream(string $target, TurboAction $action, $view, array $parameters = [], bool $addFlashes = true): Response
{
$streams = (new TurboStreamCollection())->add($target, $action, $view, $parameters);
if ($addFlashes) {
$streams->addFlashes();
}
return $this->renderTurboStreams($streams);
}
protected function renderTurboStreams(TurboStreamCollection $streams): Response
{
$response = $this->render(
'TurboResponse/turboStreamsView.html.twig',
['streams' => $streams->all()]
);
$response->headers->set('Content-Type', TurboBundle::STREAM_MEDIA_TYPE);
return $response;
}
}
What do you think about this solution ? One issue that comes to mind is any changes to the signature of render in the AbstractController will need to be replicated in the trait, but I don't think any changes are likely/expected, and I don't think a better solution is possible in PHP, but let me know if I'm missing something.
I would love some feedback since I am planning to implement this as the standard at my workplace.
P.S. I will open a PR with the code on Sunday to make further discussion easier.
After thinking a bit more, this probably should be in a TurboStreamRenderer that one could inject in controller constructor or actions (and in which we could inject Twig)
I'm just realizing the block thing is what is shown in the Turbo bundle documentation.
Please do open a PR so we can discuss :)