[Blazor] A RenderFragment's roots cannot be given a `slot` attribute
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 290
Description
### Is your feature request related to a problem? Please describe.
We maintain a Blazor wrapper library over a set of Web Components, and there is one gap we have not found a clean way to close. We would like to ask whether a narrow addition could be considered — or whether there is an approach we have missed.
Web Components receive named content through a `slot` attribute placed **on the content's own root element**. A wrapper component exposes that content as a named `RenderFragment`:
```csharp
// MyButton.razor.cs
[Parameter] public RenderFragment? Icon { get; set; }
```
So a consumer writes ordinary Razor, with no knowledge of how the component renders:
```razor
settings
Save
```
For the Web Component to receive that content, the wrapper needs to emit the first of these, and can only emit the second:
**What the component needs**
```html
settings
Save
```
**What a wrapper can emit today**
```html
settings
Save
```
The difficulty is that `Icon` arrives as a delegate. The wrapper cannot see the roots the fragment will produce, so it has no way to put `slot="icon"` on them. We are aware of three approaches, and each has a real cost.
#### 1. Wrap the fragment in a carrier element
This is the usual and very reasonable recommendation, and for most purposes it is the right answer. For Web Components specifically it does not work, because slot assignment operates on the element that is a direct child of the host. The browser assigns the `div`, so the component never receives the content's own root: `assignedElements()` returns the `div`, `::slotted(my-icon)` does not match, and the carrier participates in the component's internal layout.
`display: contents` on the carrier is a natural next thought, and we measured it in Chromium, WebKit and Firefox. It does not help. Slot assignment is a DOM-tree operation rather than a layout one, so the `div` remains the assigned node either way, and `::slotted()` cannot reach past it — the selector matches only the node that was assigned, and may not be followed by a combinator. The practical result in all three engines is that the component's sizing lands on the carrier and the content falls back to its own intrinsic size, rather than the size the component asked for.
#### 2. Ask the consumer to write `slot="…"` themselves
This is a legitimate answer and it is the one the Fluent UI Blazor library reaches for: `FluentIcon` exposes a [`Slot` parameter](https://github.com/microsoft/fluentui-blazor/blob/main/src/Core/Components/Icons/FluentIcon.razor.cs), and the [Badge sample](https://github.com/microsoft/fluentui-blazor/blob/main/examples/Demo/Shared/Pages/Badge/Badge/Examples/BadgeInSlot.razor) has the consumer supply the attribute directly:
```razor
More options...
New!
```
(Both as of v4.14.4.) We mention it not as a criticism — the team there faced the same constraint we are describing, and it is good evidence that the gap is inherent rather than something one library got wrong.
It does mean the consumer has to know that the component is built on a Web Component and that `Icon` is delivered through a slot. We have decided not to make that part of our public surface, since it is a rendering detail rather than something a Blazor developer should have to reason about.
#### 3. Render the fragment into a scratch `RenderTreeBuilder` and replay its frames
This is technically possible and can produce the right output, but it relies on `Microsoft.AspNetCore.Components.RenderTree`, which `BL0006` quite rightly flags as not intended for application code. We understand why that guidance exists, and a library resting on it would need re-validating against every .NET release — which is not a footing we would want to build on.
To be clear though: our reluctance is about the API being unsupported, not about the approach itself. If a defined, supported means of reading back and adding to a fragment's render tree were available, we would be very happy to use it as the mechanism. It is a good fit for the problem, and it may well be a more generally useful capability than the narrow overload suggested below.
### Some context on why this is awkward to resolve today
There is an asymmetry that is easy to run into. **Building** a render tree is supported — `ComponentBase.BuildRenderTree` and `RenderTreeBuilder` were confirmed as supported APIs in #60486. **Reading back** a tree that has just been produced is not, and #36976 set out clearly that those APIs are not intended to grow on external request.
We are not looking to do anything elaborate with the render tree. The need is narrow: attach one attribute to the roots of a fragment we were handed.
Related asks, for context:
| Issue | State | |
| :-- | :-- | :-- |
| #26748 — Blazor RenderFragment attributes | Open | Open since October 2020 and in the Backlog milestone. A more general form of the same need. |
| #36976 — Overwriting RenderTreeFrame within RenderTreeBuilder | Closed | *"RenderTreeBuilder APIs are not meant to be consumed by public. You can use these APIs at your own risk, but we don't plan to add new features / APIs to those based on external asks for this area."* |
| #60486 — QUESTION: RenderTreeBuilder stable/supported? | Closed | *"yes, those apis are supported"* — on `ComponentBase.BuildRenderTree` and `RenderTreeBuilder`. |
| #41138 — Allow knowing before render if `RenderFragment` will return null or empty | Closed | *"RenderFragment is a delegate and we can't extend it… implementing logic like the one you suggest would require running the code in the delegate, which is expensive."* |
The cost concern raised on that last one seems well founded: any approach along these lines has to invoke the delegate. For this narrower case — one attribute, applied as the fragment is already being rendered — the tradeoff may look different, but we appreciate it is a real consideration rather than an incidental one.
### Describe the solution you'd like
A narrow overload that applies attributes to the root frames of a fragment as it is rendered. This would not require exposing `RenderTreeFrame` or treating the render tree as a public contract, since the renderer already knows which frames are roots at the point it writes them:
```csharp
builder.AddContent(sequence, fragment,
new Dictionary { ["slot"] = "icon" });
```
The semantics we would need:
- Attributes apply to each **top-level** frame the fragment produces, not to descendants.
- A component root receives it as a parameter, so it reaches that component's own `CaptureUnmatchedValues` splat.
- A text-only root cannot carry an attribute. Either throwing or ignoring would work for us, as long as the behaviour is defined.
- A fragment that renders nothing emits nothing.
The exact shape matters less to us than having a supported route. We can help with a resolution concretely: supplying a fuller reproduction repository, testing a candidate API against a production Web Component library across Chromium, WebKit and Firefox, and reviewing a proposed shape against real wrapper cases beyond the simple one above.
### Minimal reproduction
A Web Component that styles and measures its own slotted content:
```js
customElements.define("my-button", class extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: "open" }).innerHTML = `
::slotted([slot=icon]) { width: 18px; height: 18px }
`;
}
});
```
A wrapper, using the only shape available today:
```razor
@* MyButton.razor *@
@ChildContent
@code {
[Parameter] public RenderFragment? Icon { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
}
```
Used as:
```razor
settings
Save
```
Observed: `slot[name=icon].assignedElements()` returns the `div`, not `my-icon`, and the `::slotted([slot=icon])` rule sizes the `div` while `my-icon` keeps its own intrinsic size. Expected, and what the component was written for: the `my-icon` element itself carries `slot="icon"` and is the assigned node.
### Additional context
The carrier route has a consequence beyond the component's own rendering. To make it work, the Web Components themselves have to be taught to recognise a wrapper element and look through it — in their slot-assignment queries, their `::slotted()` rules, and anywhere they reason about their own light-DOM children. That is only possible at all when the Web Components are under the same author's control, and where they are, it means a general-purpose Web Component library ends up carrying knowledge that it might be consumed by a Blazor application. A Web Component should not need to know which framework is rendering into it; that is rather the point of the standard.
Blazor's HTML abstraction is a real strength, and we appreciate that a gap like this is a natural consequence of having one at all rather than an oversight. This case comes up for any library wrapping Web Components, and today the choices are the carrier element or asking consumers to write `slot` attributes themselves.
If there is a supported approach we have overlooked, we would welcome being pointed at it — that would be a better outcome than a new API.
---
*This issue was written with the assistance of Claude. The measurements, the linked issues and the quotations were each checked against the sources before being included.*
Contributor guide
Research direction
Start with RenderTreeBuilder.AddContent and ComponentBase.BuildRenderTree, then read the constraints documented in issues #26748, #36976, #60486, and #41138. Done means a supported way to apply attributes to each top-level RenderFragment frame, with defined behavior for component roots, text-only roots, and empty fragments.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, javascript
- Domain
- frontend, web-dev
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 32/100