Resurrecting Html.Action in ASP.NET Core
MVC5 child actions were removed from ASP.NET Core, so this codebase re-implemented them - an ActionContext conjured mid-request, a MemoryStream response swap, and a .Result at the heart of 118 call sites.
There are 118 calls to Html.Action across twenty-nine views in the attendance platform — and Html.Action does not exist in ASP.NET Core. The framework removed it, deliberately, with a documented replacement. This codebase brought it back from the dead instead, and the resurrection is the single most instructive file in the solution: a compact tour of the MVC internals most of us never touch, wrapped around a sync-over-async time bomb. After the authentication and authorisation seams of part three, this is where the view layer gets its turn.
First, the history. In MVC5, child actions let a view embed the output of a controller action: @Html.Action("BirthdayCard", "Employee") ran the action — filters, model, partial view and all — and spliced the HTML into the parent page. Teams used them to make dashboards compositional: each card on the page was its own action with its own data access. ASP.NET Core removed child actions and offered view components as the successor — same idea, but a first-class citizen with an async contract and no re-entry into the MVC pipeline.
The attendance platform was ported from an MVC5 codebase, and its views were saturated with Html.Action. Faced with 118 call sites, the team made a call I understand completely and will spend this article both explaining and warning about: they re-implemented the missing API rather than migrate the call sites.
The resurrection, line by line
The whole trick lives in one extension method. This is a fresh illustration of the technique, trimmed to its load-bearing parts:
public static IHtmlContent Action(this IHtmlHelper helper,
string action, string controller, object parameters = null)
{
var httpContext = helper.ViewContext.HttpContext;
var services = httpContext.RequestServices;
var routeData = new RouteData();
routeData.Values["controller"] = controller;
routeData.Values["action"] = action;
var actionDescriptor = services
.GetRequiredService<IActionSelector>()
.SelectCandidates(new RouteContext(httpContext) { RouteData = routeData })
.First();
var actionContext = new ActionContext(httpContext, routeData, actionDescriptor);
var invoker = services.GetRequiredService<IActionInvokerFactory>()
.CreateInvoker(actionContext);
var originalBody = httpContext.Response.Body;
using var buffer = new MemoryStream();
httpContext.Response.Body = buffer;
try
{
invoker.InvokeAsync().Result; // the heart of it
buffer.Position = 0;
using var reader = new StreamReader(buffer);
return new HtmlString(reader.ReadToEnd());
}
finally
{
httpContext.Response.Body = originalBody;
}
}
Read it slowly, because every line teaches something about how MVC actually works.
- The
RouteDatais conjured, not routed. No URL was matched; the method fabricates route values and asksIActionSelectorto find a matching action descriptor, exactly as the routing middleware would have. This is the moment you realise MVC's dispatch is just services you can call yourself. - A second
ActionContextis born inside the first. The original also swapped it intoIActionContextAccessor, so anything downstream asking “what action am I in?” got the child's answer — and, if an exception escaped before the swap-back, kept getting it for the rest of the request. - The response body is swapped for a
MemoryStream. The child action believes it is writing an HTTP response; it is writing into a buffer that becomes anHtmlString. Elegant — and it means anything the child does to headers (cookies, caching, status codes) escapes the buffer and lands on the real response, or is silently lost if the headers were already sent. The child looks sandboxed. It is only body-sandboxed. - The full pipeline runs again.
CreateInvokergives you the real invoker: model binding, action filters, result execution. EveryHtml.Actionis a complete MVC request minus the HTTP parts — which is faithful to MVC5's semantics and precisely why MVC5's version was slow, and why the Core team declined to rebuild it.
The .Result at the heart of it
invoker.InvokeAsync().Result is the line I want on the whiteboard. The invoker is async to its bones; the IHtmlHelper extension must be synchronous because Razor's @Html.Action(...) expression syntax is. So the method blocks a request thread until the child action — including its EF Core queries — completes.
In classic ASP.NET this exact construction deadlocked instantly, because the captured SynchronizationContext needed the blocked thread back. ASP.NET Core has no synchronisation context, so it does not deadlock — it does something slower to diagnose. Each blocked thread is subtracted from the thread pool while it waits on I/O that the thread pool also needs threads to complete. Under light load, nothing. Under a Monday-morning spike — everyone opening dashboards at once, each dashboard fanning out into four or five child actions, each child action awaiting SQL — the pool ratchets up injection delays and requests queue behind threads that are doing nothing but waiting. The failure signature is maddening: CPU low, database idle, latency terrible. And exceptions from the child arrive wrapped in AggregateException, so even the error reports wear a disguise.
Sync-over-async does not fail; it degrades, and it degrades in production-shaped load patterns you will never reproduce on a laptop.
118 call sites is an architecture
It is worth sitting with the number. Twenty-nine views composed themselves from child actions — dashboard cards, approval widgets, the birthday panel, header fragments. This was not a shim covering a few stragglers; the compositional style of the application depended on a framework feature the framework had removed. The extension method was, in effect, a privately maintained fork of a deleted subsystem, with one maintainer, no tests, and behaviour that diverged from both MVC5 (headers, action-context leakage) and idiomatic Core.
To be fair to the decision: with a port deadline, 118 mechanical edits across twenty-nine views — each needing a new view component class, an InvokeAsync, and a moved view file — is real risk too. A one-file shim that makes the old code correct-enough today is a rational trade. The failure was not making the shim; it was never writing down that the shim was a loan.
What a view component costs, for comparison
The sanctioned replacement is not exotic. A dashboard card as a view component:
public class BirthdayCardViewComponent : ViewComponent
{
private readonly IEmployeeData _employees;
public BirthdayCardViewComponent(IEmployeeData employees) => _employees = employees;
public async Task<IViewComponentResult> InvokeAsync(int employeeId)
{
var model = await _employees.GetBirthdaysAsync(employeeId);
return View(model);
}
}
And in the view: @await Component.InvokeAsync("BirthdayCard", new { employeeId }). The differences are exactly the shim's weaknesses inverted: genuinely async end-to-end, no pipeline re-entry (no second filter run, no fabricated routing), constructor injection instead of service location, and a unit-testable class. What you give up is the ability to reuse an existing controller action as a page fragment — which is the whole temptation, and the thing MVC5 arguably should never have encouraged.
The honest ledger
What I take from this file, having lived with it:
- Read the removal notes before resurrecting an API. Child actions were not lost in the port to Core; they were removed with reasons — pipeline re-entry and sync rendering — and the resurrection faithfully reproduced the reasons along with the feature.
- A shim is a loan against migration. Taking the loan was defensible. Carrying it for years, while new views added new
Html.Actioncalls because the pattern looked native, is how 118 call sites happen. - Count your call sites while they are countable. At ten, migration is an afternoon. At 118, it is a project nobody will schedule.
The views lean on this machinery; the data they render comes from an EF Core model with its own archaeology — a database-first model dressed in code-first clothing, complete with a DbSet<List<string>> that scaffolded a real table. That is the next part.