#13737·MudBlazor

Reduce number of components that are rendered per item

Author: danielchalmersCreated Aug 27, 2026Updated Sep 15, 2026
Labelsperformance
### Summary MudBlazor uses components as its styling vocabulary: a MudText to emit a styled `

`, a MudIcon for an ``, a MudElement for a `

`. Each of those costs a full Blazor component, and in a repeated list that choice dominates everything else. The rule of thumb this issue tracks: Components at the widget boundary, markup inside it. A component may compose other components at its top level, but the content it repeats per item should be plain elements and CSS classes. ### Status by release #### v9.9.0 (released 2026-08-24) Nothing in this release removed components per item. #### v9.10.0 (released 2026-09-13) | PR | what | | --- | --- | | #13707 | MudText renders its element directly | | #13731 | MudStack, MudButtonGroup, MudRTLProvider render their element directly | | #13793 | MudTreeViewItem skips the toggle button for leaves | | #13802 | MudTr renders the selection cell as plain markup | | #13816 | MudPagination renders its list element directly | #### v9.11.0 (current) Merged: | PR | what | | --- | --- | | #13800 | MudButton, MudIconButton, MudFab render their root directly | | #13794 | MudListItem renders its row directly | | #13801 | MudMenuItem renders its row directly | | #13815 | MudChip renders its element directly | | #13817 | AGENTS.md "Root element" rule, `MudElement.CaptureRef` removed | | #13843 | MudListItem, MudMenuItem, MudTreeViewItem, MudCheckBox, MudRadio, MudSwitch render their text without MudText | | #13868 | MudDatePicker's year view renders its 201 years as markup instead of MudText components (timings in #13856) | Retained memory against v9.10.0, 100 instances unless it is a page, median of 5. The numbers include every change merged for v9.11.0, not only the PRs above: | scenario | v9.10.0 | dev | change | | --- | --- | --- | --- | | list items | 2034 KB | 773 KB | -62% | | open menu | 2243 KB | 914 KB | -59% | | buttons | 1097 KB | 541 KB | -51% | | open select | 3248 KB | 1905 KB | -41% | | admin page (app shell, filters, grid with row actions) | 1072 KB | 842 KB | -21% | | settings form | 827 KB | 710 KB | -14% | v9.11.0 density work is complete. #### v9.x (future) No more density work until the changes from v9.9.0 through v9.11.0 have proven stable, so the items below will probably land in v9.13.0 or later. - MudSelectItem rendering its own row instead of wrapping MudListItem, one extra component per option. At realistic option counts (under a few hundred) it is only worth doing if it stays DOM-identical and small. #### v10 (next breaking release) These change the DOM or a public API. | change | measured | what breaks | | --- | --- | --- | | Render a MudMenu or MudTooltip popover only while it is open | 100 closed menus -56%, DataGrid 100 columns -36% (column options menus), 100 tooltips -22%, admin page -12% | A closed menu or tooltip no longer emits its empty `mud-popover` element in the provider or the `mud-popover-cascading-value` placeholder next to the activator. The first-open transition needs checking. | | MudTable cells as markup, the way MudDataGrid does it | 100 rows 1787 -> 724 KB (-60%); `` produces the same DOM as MudTd | `RowTemplate` API | | MudSelect without shadow items | a closed select holds one MudSelectItem per option, 4.4 KB each, at page load; also removes the per-open shadow item rebuild #13851 keeps | `IsValueInList` and `CanRenderValue` need replacing; `Strict` and the displayed text depend on them | | Input rework | a MudTextField is about 42 KB and 6 components; MudInputControl and MudInputLabel as markup: switches -27%, text fields -12%, checkboxes -9%, settings form -7%; dropping the two `SubscribeToParentForm` cascades: at most -10% per field | the cascade becomes a parameter, and the markup change touches every input | | Icon inlining, on hold | 2.4-5.7 KB retained per icon; see below | nothing in the DOM; held as not worth the extra code in a minor | ##### Icon inlining (prototyped for v9.11.0, put on hold) Library components would call an internal `MudIcon.RenderIcon(...)` fragment, beside `MudText.RenderText`, instead of creating a MudIcon. It was built at 38 call sites in 24 components with an identical DOM across 108 scenarios and a green suite. It was held because the gain is modest for the extra code, MudInputAdornment and MudSwitch's thumb could not use it as built, and interactive parity was never A/B tested. Retained memory saved against `dev` after #13843: | scenario | saved | | --- | --- | | per MudRating (5 stars) | 13.0 KB | | per checkbox, radio, numeric field, expansion panel or DataGrid column | 5.6-5.7 KB | | admin page | 72 KB of 843 KB | | toolbar, tabs, fields and a 25-row table with ratings | 376 KB of 923 KB | Notes for picking it back up - Call it as an expression, `@MudIcon.RenderIcon(...)`, so it renders into the builder in scope (including child-content lambdas) and keeps the whitespace node before the icon in MudCheckBox, MudRadio and MudRatingItem. - An inline icon is rebuilt with its parent. Cache the class string per (size, color, disabled, svg) to keep that near 0.3 KB per icon per parent render. - Match CssBuilder for a custom class (`null`, `""` and whitespace add nothing; trim the result), and cover the helper with one parity test over every icon kind, color, size, disabled and class value. - MudInputAdornment needs `aria-label` and `tabindex`, and MudSwitch's thumb a style, including MudIcon's rule that a non-blank `aria-label` removes `aria-hidden`. - Skip icons that appear once per page. Tests using `FindComponent()` inside converted components have to query the DOM, and the release notes need the same line as #13843. ### Measured and dropped Too small at realistic item counts, or already covered by the items above: - Fixed cascades and render guards on MudMenu, MudRating, MudTimeline, MudNavMenu, MudNavGroup and MudCarousel: -9% to -16% retained on that component. Their re-render cost is tracked in #13856; the MudCard one (#13867) was closed as not worth the maintenance cost. - MudNavLink computing its own active class instead of wrapping NavLink: at most -22% on a nav menu and -4% on the admin page, and it would re-implement NavLink's matching. - MudChip close button as markup: after icon inlining a closable chip is two components; the rest is a few KB per chip. - DataGrid sort button: covered by icon inlining. - MudBreadcrumbs inlining its item components: about 6 KB per crumb. - Earlier: the `OpenRoot` disposable form, MudTabs skipping MudTooltip, hoisting DataGrid's validator cascade, MudRadio inlining its label, deleting `MudList.Update()`, caching `RenderFragment`s and chasing `enum`, `Guid`, `EventCallback` or nullable parameters. #13680, #13694 and #13702 were closed as noise. ### Traps - `class` and `style` precedence depends on where the `UserAttributes` splat sits: a caller's value replaces the computed one on about 23 components and is dropped on about 87. Keep the order a component had and pin it with tests. - A lowercase `class` passed to a component binds to its `Class` parameter, because parameter names match case-insensitively. Markup that replaces the component has to merge it the same way. - A consumer test using `FindComponent()`, `FindComponent()` or `FindComponent()` inside a library component stops finding it. Call it out in the release notes. - Inlining a component that wrapped `ChildContent` creates the components inside it one render step earlier, which can change order-dependent state such as `MudForm.Errors`. ### Reference Where the time goes 1000 list rows in the same headless Chrome, Debug WASM test viewer (a Release build measures the same): | layer | time | | --- | --- | | native `` with 1000 ` How the wrapper removals are written A library component renders its own root and never wraps itself in MudElement. A fixed tag is markup. A runtime tag is opened from C# with `builder.OpenElement(0, HtmlTag)` and the content stays as markup in a `RenderTreeBuilder __builder` method, which is how [QuickGrid](https://github.com/dotnet/aspnetcore/blob/4b58ad51259280e1340777513694f122501435ab/src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor#L41-L110) renders rows and [Virtualize](https://github.com/dotnet/aspnetcore/blob/4b58ad51259280e1340777513694f122501435ab/src/Components/Web/src/Virtualization/Virtualize.cs#L554) renders its `SpacerElement`. The reasoning is [Avoid thousands of component instances](https://learn.microsoft.com/aspnet/core/blazor/performance/rendering#avoid-thousands-of-component-instances). The rule is in AGENTS.md. MudElement stays as a public component for consumers. What a component costs Retained per instance, each measured by removing a known number of instances: | kind | retained | | --- | --- | | raw markup | ~0.04 KB | | CascadingValue | 1.0 KB | | bare `IComponent` | 1.9 KB | | MudComponentBase subclass | 3-5 KB | | MudPopover | 10.5 KB | Being a component at all costs 20-35x the markup it produces, so the count is what matters. The saving per removed component is smaller inside a parent whose render tree is already large, which is why a page gains less than a per-item scenario. What decides whether an idea can work 1. Change detection compares value types and `string` by `Equals`; every other reference type is assumed changed. A cached `RenderFragment` measures identical to an inline lambda, so any component taking `ChildContent` re-renders on every parent render. 2. Repeated renders retain memory in proportion to emitted markup. Mount amplification is a permanent per-circuit cost, not just CPU. 3. The first MudPopover on a page brings about 45 KB of shared setup. Sweep N and take the marginal. 4. A component boundary defers fragment evaluation by one render-queue step. Removing it can change render order, not just markup. 5. The bare renderer and bUnit show each option rendering once when a select opens; the browser shows seven. Count renders in the browser before concluding a render path is cheap (#13856 has the probe). How these numbers were taken Bare `Renderer` with a no-op `UpdateDisplayAsync`, Release, retained memory after a full collection with the tree still rooted, component count from the live render tree, median of 3 to 5 runs. DOM parity from an HtmlRenderer dump after normalising generated ids. Browser times are headless Chrome against the Debug WASM test viewer, from `mousedown` to 400 ms of no DOM changes, median of 6 opens over 4 runs. Behaviour was checked by an A/B driver that runs the same interactions against two builds and diffs the normalized DOM, focus and input state after every step.