Fluid icon indicating copy to clipboard operation
Fluid copied to clipboard

Pass custom rendering context to component slot closures

Open jramke opened this issue 6 months ago • 7 comments

Hey,

First of all, the new component feature is a game changer, thanks a lot for that.

Im currently playing around with some composable composition api that lets you use components like this:

<ui:dialog.root>
    <ui:dialog.trigger>Open Dialog</ui:dialog.trigger>
    <ui:dialog.content>
        <ui:dialog.title>Hello from the Dialog</ui:dialog.title>
        Here is the Dialog content.
    </ui:dialog.content>
</ui:dialog.root>

This works great with a custom ComponentCollection and ComponentRenderer. However, I’d love to add something like an asChild prop (similar to Radix UI), so that I can control how subcomponents are rendered if needed. For example:

<ui:dialog.trigger asChild="{true}">
    <ui:button variant="secondary" {ui:ref(name: 'trigger')}>Open Dialog</ui:button>
</ui:dialog.trigger>

The subcomponents such as dialog.title typically look like this:

<h2 {ui:ref(name: 'title' )} {ui:attributes()}>
    <f:slot />
</h2>

The ref and attributes viewhelpers handle referencing for the client-side component library and additional attributes. However this viewhelper relies heavily on the parents rendering context to get the components context. Im currently rendering the slots like this directly in the componentRenderer when the asChild prop is set:

$rendered = isset($slots['default']) && is_callable($slots['default']) ? (string)$slots['default']() : '';

This works, but I’d like to pass a custom RenderingContext (e.g. one including component and parent template variables) into the slot closure when rendering it manually.

Modifying the buildRenderChildrenClosure in the componentAdapter like so allows this to work for uncached components:

private function buildRenderChildrenClosure(): callable
    {
        return function (RenderingContextInterface|null $renderingContext = null): mixed {
            $renderingContext = $renderingContext ?? $this->renderingContext;
            $this->renderingContextStack[] = $renderingContext;
            $result = $this->viewHelperNode->evaluateChildNodes($renderingContext);
            $this->setRenderingContext(array_pop($this->renderingContextStack));
            return $result;
        };
    }

I think, making this work for cached components/templates would be significantly more complex but perhaps there's a better idea or an existing mechanism I’ve missed?

Thanks again for the amazing work!

PS: Maybe this is could be related to #1128

Best regards, Joost

jramke avatar Jul 05 '25 22:07 jramke

Hi Joost,

first of all, thank you for your positive feedback! I don't know if I understood your use case correctly in detail, but I have two features planned that might be helpful to you:

  1. Named Slots
  2. Arguments for Named Slots

In practice, you would call a slot from within a component like this:

<f:slot name="listItem" arguments="{dataForThisItem}" />

and you would call the component like this:

<my:component>
  <f:fragment name="listItem" argumentsAs="item">
    <my:subComponent title="{item.title}" />
  </f:fragment>

This would properly separate the integration/mapping from the component code. Maybe this covers some of the things you want to achieve?

s2b avatar Jul 05 '25 22:07 s2b

Hi Simon,

Thanks for your quick response.

I already took a look into your named slots PR, and while it’s a great addition, I think it tackles a slightly different use case. I imagine the argumentsAs feature for fragments could be helpful for what I’m exploring, but I assume that part isn’t included in the PR yet?

What I’m trying to build is a composition model similar to what Radix UI describes here.

For example, with named slots I always need to predefine possible insertion points in the component itself. This tends to create a tight coupling between the component’s internal structure and its external usage.

That’s where the idea of an asChild prop comes in. Instead of the component always rendering its own markup, it gives the consumer full control over the rendered DOM structure, while still preserving access to the component context, e.g. for applying needed attributes for the js to the html element.

Best regards, Joost

jramke avatar Jul 06 '25 09:07 jramke

Ok, I'm still very much confused by this concept, especially since it probably relies on the way React handles/generates HTML tags (which is not at all how Fluid does it). But I think that my argumentsAs feature – which is not yet implemented in the PR – will also require passing a custom RenderingContext to slot closures. So this will probably a good basis for you to implement what you want.

s2b avatar Jul 07 '25 11:07 s2b

Yeah i know that, looking forward to the argumentsAs feature :)

jramke avatar Jul 07 '25 11:07 jramke

Can you specify what exactly you would like to add to the rendering context? I've come to the conclusion that it's best to keep the "outer" rendering context and modify it inside of the closure if necessary. For my use case, this could look something like this:

    private function buildRenderChildrenClosure(): callable
    {
        return function (array $extraVariables = []): mixed {
            $originalVariableProvider = $this->renderingContext->getVariableProvider();
            if ($extraVariables !== []) {
                $localVariableProvider = new StandardVariableProvider($extraVariables);
                $this->renderingContext->setVariableProvider(new ScopedVariableProvider($originalVariableProvider, $localVariableProvider));
            }

            $this->renderingContextStack[] = $this->renderingContext;
            $result = $this->viewHelperNode->evaluateChildNodes($this->renderingContext);
            $this->setRenderingContext(array_pop($this->renderingContextStack));

            $this->renderingContext->setVariableProvider($originalVariableProvider);

            return $result;
        };
    }

My guess is that this doesn't solve your problem then?

s2b avatar Jul 25 '25 09:07 s2b

It's mainly just a context variable, I guess, so this addition should work :) Keeping the outer rendering context and its variables definitely makes sense. 👍

jramke avatar Jul 25 '25 12:07 jramke

see also #1136

s2b avatar Aug 17 '25 17:08 s2b