Skip to content

[Feature Idea] [Turbo Streams] [ux-turbo] Render view as Turbo Stream #2068

Open
@DRaichev

Description

@DRaichev

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions