The entire process is quite involved, and is managed internally by the ResourceInvoker class, but the main piece of plumbing responsible for handling a ViewResult is the ViewExecutor class (source code).
The primary entry point of the ViewExecutor is the ExecuteAsync() method, which accepts the ActionContext and your ViewResult. It then locates the appropriate view using the registered IViewEngine (typically the RazorViewEngine) to identify the corresponding view and return a ViewEngineResult instance. That ViewEngineResult is then used to execute and render the view.
Extending ViewExecutor
If you need to customize the logic, you can implement your own view executor class by implementing the IActionResultExecutor<> interface and then registering it as a singleton using ASP.NET Core's dependency injection container. When doing so, I recommend referencing the out-of-the-box ViewResultExecutor's source code.
This can be useful if you want to implement custom logic for locating the view. For instance, perhaps you want to account for state or context data such as route data, request headers, cookies, session, &c. when locating the view.
So, as a really simple example, let us say you want to allow a view to be optionally selected using a query string value (e.g., ?View=MyView). In that case, you might create a QueryStringViewResultExecutor. Here's a basic proof-of-concept:
public class QueryStringViewResultExecutor : ViewExecutor, IActionResultExecutor<ViewResult>
{
public QueryStringViewResultExecutor(
IOptions<MvcViewOptions> viewOptions,
IHttpResponseStreamWriterFactory writerFactory,
ICompositeViewEngine viewEngine,
ITempDataDictionaryFactory tempDataFactory,
DiagnosticListener diagnosticListener,
IModelMetadataProvider modelMetadataProvider
) : base(
viewOptions, writerFactory, viewEngine, tempDataFactory, diagnosticListener, modelMetadataProvider
)
{
}
public async Task ExecuteAsync(ActionContext context, ViewResult result)
{
var viewEngineResult = FindView(context, result); // See helper method below
viewEngineResult.EnsureSuccessful(originalLocations: null);
var view = viewEngineResult.View;
using (view as IDisposable)
{
await ExecuteAsync(
context,
view,
result.ViewData,
result.TempData,
result.ViewName,
result.StatusCode
).ConfigureAwait(false);
}
}
private ViewEngineResult FindView(ActionContext actionContext, ViewResult viewResult)
{
// Define variables
var view = (ViewEngineResult?)null;
var viewEngine = viewResult.ViewEngine?? ViewEngine;
var searchedPaths = new List<string>();
var requestContext = actionContext.HttpContext.Request;
// If defined, attempt to locate view based on query string variable
if (requestContext.Query.ContainsKey("View"))
{
var queryStringValue = requestContext.Query["View"].First<string>();
if (queryStringValue is not null)
{
view = viewEngine.FindView(actionContext, queryStringValue, isMainPage: true);
searchedPaths = searchedPaths.Union(view.SearchedLocations?? Array.Empty<string>()).ToList();
}
}
// If no view is found, fall back to the view defined on the viewResult
if (!view?.Success?? true)
{
view = viewEngine.FindView(actionContext, viewResult.ViewName, isMainPage: true);
searchedPaths = searchedPaths.Union(view.SearchedLocations ?? Array.Empty<string>()).ToList();
}
// Return view for processing by the razor engine
if (view is not null and { Success: true }) {
return view;
}
return ViewEngineResult.NotFound(viewResult.ViewName, searchedPaths);
}
}
You would then register this in your Startup.ConfigureServices() method as:
services.Services.TryAddSingleton<IActionResultExecutor<ViewResult>, QueryStringViewResultExecutor>();
Disclaimer: You may need to unregister the out-of-the-box ViewExecutor in order to avoid a conflict. Typically, however, you are registering an IActionResultExecutor<> with a custom ViewResult and, thus, that isn't necessary; see below.
Extending ViewResult
Often, you will want to pair this with a custom ViewResult. Why is this useful? Usually because you need to pass additional data to your ViewResultExecutor from your Controller.
So, as a contrived example, imagine that you have themes, and views can optionally be customized based on that theme. You might then add a Theme property to your ViewResult, thus allowing the ViewResultExecutor to first look for a view based on the theme, and otherwise fallback to the non-themed version.
public class ThemedViewResult : ViewResult
{
public string Theme { get; set; }
public override async Task ExecuteResultAsync(ActionContext context)
{
var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<ThemedViewResult>>();
await executor.ExecuteAsync(context, this).ConfigureAwait(false);
}
}
Even if you don't need a custom ViewResult, it's worth pausing for a second to evaluate this code. The underlying ViewResult class implements IActionResult, which exposes a single method, ExecuteResultAsync(). This is called by the ResourceInvoker class mentioned at the top. This in turn locates the registered IActionResultExecutor<>—i.e., the type of component we created and registered in the previous section—and calls its ExecuteAsync() method.
Note: This is a contrived example because often a theme would be accessible by other context data, such as the RouteData, a custom ClaimsPrincipal, or an ISession implementation. But you can imagine other times where this information would be request-specific, and best relayed from the Controller via the ViewResult. For instance, perhaps a CMS where the theme can be selected on a per page basis.
As it's unclear whether you actually need to extend this functionality or are just curious how everything fits together, the above examples are only meant as a proof-of-concept; they aren't tested. Still, they should give you a basic idea of where and how the ViewResult is handled, as well as options for extending that behavior should you need.