diff --git a/InertiaCore/Contracts/IInertia.cs b/InertiaCore/Contracts/IInertia.cs
new file mode 100644
index 0000000..1317123
--- /dev/null
+++ b/InertiaCore/Contracts/IInertia.cs
@@ -0,0 +1,105 @@
+using InertiaCore.Props;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InertiaCore.Contracts;
+
+///
+/// Main Inertia.js service interface for server-side adapter operations.
+/// Inject this interface into your controllers and services via constructor injection.
+///
+public interface IInertia
+{
+ ///
+ /// Render an Inertia page component with the given props.
+ ///
+ /// The JavaScript component name (e.g. "Admin/Users").
+ /// Optional props object, anonymous type, or dictionary.
+ /// An that returns either a JSON envelope or a full page view.
+ Task Render(string component, object? props = null);
+
+ ///
+ /// Return an external redirect via an Inertia Location response (409 + X-Inertia-Location).
+ ///
+ IActionResult Location(string url);
+
+ ///
+ /// Share a value across all Inertia responses for the current request.
+ ///
+ /// The prop key (will be camelCased).
+ /// The value to share.
+ void Share(string key, object? value);
+
+ ///
+ /// Share multiple values across all Inertia responses for the current request.
+ ///
+ void Share(IDictionary data);
+
+ ///
+ /// Share a flash prop that will only be available for the current request.
+ /// Flash props are merged after shared props but before component props.
+ ///
+ void Flash(string key, object? value);
+
+ ///
+ /// Set the asset version to a fixed string.
+ ///
+ void Version(string? version);
+
+ ///
+ /// Set the asset version to a lazily-evaluated function.
+ ///
+ void Version(Func versionFunc);
+
+ ///
+ /// Get the currently configured asset version.
+ ///
+ string? GetVersion();
+
+ ///
+ /// Create an AlwaysProp that will always be included in all responses,
+ /// even during partial reloads where its key would otherwise be excluded.
+ ///
+ AlwaysProp Always(object? value);
+
+ ///
+ /// Create an AlwaysProp with a synchronous factory.
+ ///
+ AlwaysProp Always(Func callback);
+
+ ///
+ /// Create an AlwaysProp with an asynchronous factory.
+ ///
+ AlwaysProp Always(Func> callback);
+
+ ///
+ /// Create a LazyProp that is excluded from the initial page load
+ /// and only resolved on subsequent partial reloads.
+ ///
+ LazyProp Lazy(Func callback);
+
+ ///
+ /// Create a LazyProp with an asynchronous factory.
+ ///
+ LazyProp Lazy(Func> callback);
+
+ ///
+ /// Create a DeferredProp that is excluded from the initial full page load
+ /// and resolved only when explicitly requested via X-Inertia-Partial-Data.
+ ///
+ DeferredProp Defer(Func factory);
+
+ ///
+ /// Create a DeferredProp with an asynchronous factory.
+ ///
+ DeferredProp Defer(Func> factory);
+
+ ///
+ /// Set whether to encrypt history state on the client side.
+ ///
+ void EncryptHistory(bool encrypt = true);
+
+ ///
+ /// Set whether to clear history state on the client side.
+ ///
+ void ClearHistory(bool clear = true);
+}
diff --git a/InertiaCore/Extensions/Configure.cs b/InertiaCore/Extensions/Configure.cs
index 867a3fa..7a4419d 100644
--- a/InertiaCore/Extensions/Configure.cs
+++ b/InertiaCore/Extensions/Configure.cs
@@ -1,5 +1,7 @@
using System.Net;
+using InertiaCore.Contracts;
using InertiaCore.Models;
+using InertiaCore.Services;
using InertiaCore.Ssr;
using InertiaCore.Utils;
using Microsoft.AspNetCore.Builder;
@@ -7,15 +9,23 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
namespace InertiaCore.Extensions;
public static class Configure
{
+#pragma warning disable CS0618 // Internal usage of the deprecated static facade is intentional
public static IApplicationBuilder UseInertia(this IApplicationBuilder app)
{
- var factory = app.ApplicationServices.GetRequiredService();
- Inertia.UseFactory(factory);
+ // IResponseFactory is scoped (shares InertiaState with the per-request IInertia).
+ // We create a scope here to resolve it without triggering the "scoped from root"
+ // validation. The scope lives for the app lifetime since the factory is held
+ // by the static Inertia facade.
+ var startupScope = app.ApplicationServices.CreateScope();
+ var factory = startupScope.ServiceProvider.GetRequiredService();
+ var contextAccessor = app.ApplicationServices.GetRequiredService();
+ Inertia.UseFactory(factory, contextAccessor);
var viteBuilder = app.ApplicationServices.GetService();
if (viteBuilder != null)
@@ -26,9 +36,13 @@ public static IApplicationBuilder UseInertia(this IApplicationBuilder app)
app.Use(async (context, next) =>
{
- if (context.IsInertiaRequest()
- && context.Request.Method == "GET"
- && context.Request.Headers[InertiaHeader.Version] != Inertia.GetVersion())
+ // Per the Inertia.js protocol, asset version conflicts must be reported
+ // on ALL Inertia requests, not just GET.
+ // See: https://inertiajs.com/the-protocol#asset-versioning
+ var serverVersion = Inertia.GetVersion();
+ if (serverVersion != null
+ && context.IsInertiaRequest()
+ && context.Request.Headers[InertiaHeader.Version] != serverVersion)
{
await OnVersionChange(context, app);
return;
@@ -46,7 +60,25 @@ public static IServiceCollection AddInertia(this IServiceCollection services,
services.AddHttpContextAccessor();
services.AddHttpClient();
- services.AddSingleton();
+ // Per-request state container — seed Version from InertiaOptions so that
+ // the asset version is available consistently across the middleware check
+ // and response body without requiring middleware wiring or static facade calls.
+ services.AddScoped(sp =>
+ {
+ var version = sp.GetRequiredService>().Value.Version;
+ return new InertiaState { Version = version };
+ });
+
+ // InertiaService is the public API that consumers should inject via IInertia.
+ // Registered as Scoped — one instance per HTTP request.
+ services.AddScoped();
+
+ // Internal factory — scoped so it shares the same per-request InertiaState
+ // as InertiaService, ensuring Share(), Flash(), and Version calls via IInertia
+ // are visible to the response pipeline.
+ services.AddScoped();
+
+ // Gateway is safe as Singleton since IHttpClientFactory manages HttpClient lifetimes.
services.AddSingleton();
services.Configure(mvcOptions => { mvcOptions.Filters.Add(); });
@@ -78,3 +110,4 @@ private static async Task OnVersionChange(HttpContext context, IApplicationBuild
await context.Response.CompleteAsync();
}
}
+#pragma warning restore CS0618
diff --git a/InertiaCore/Extensions/InertiaExtensions.cs b/InertiaCore/Extensions/InertiaExtensions.cs
index 192b424..cc5ecad 100644
--- a/InertiaCore/Extensions/InertiaExtensions.cs
+++ b/InertiaCore/Extensions/InertiaExtensions.cs
@@ -32,23 +32,21 @@ internal static bool Override(this IDictionary dicti
return true;
}
- internal static Task ResolveAsync(this Func func)
+ internal static async Task ResolveAsync(this Func func)
{
- var rt = func.Method.ReturnType;
+ var result = func();
- if (!rt.IsGenericType || rt.GetGenericTypeDefinition() != typeof(Task<>))
- return Task.Run(func.Invoke);
+ if (result is Task task)
+ {
+ return await task.UnwrapResultAsync();
+ }
- var task = func.DynamicInvoke() as Task;
- return task!.ResolveResult();
+ return result;
}
- internal static async Task ResolveResult(this Task task)
+ internal static Task ResolveResult(this Task task)
{
- await task.ConfigureAwait(false);
- var result = task.GetType().GetProperty("Result");
-
- return result?.GetValue(task);
+ return task.UnwrapResultAsync();
}
internal static string MD5(this string s)
diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs
index d13b932..32ff5ef 100644
--- a/InertiaCore/Inertia.cs
+++ b/InertiaCore/Inertia.cs
@@ -1,43 +1,137 @@
using System.Runtime.CompilerServices;
+using InertiaCore.Contracts;
using InertiaCore.Props;
using InertiaCore.Utils;
using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Http;
[assembly: InternalsVisibleTo("InertiaCoreTests")]
namespace InertiaCore;
+///
+/// Static facade for Inertia.js operations.
+///
+/// This class is deprecated. Inject via constructor injection
+/// instead. This static facade will be removed in a future major version.
+///
+///
+/// public class MyController : Controller
+/// {
+/// private readonly IInertia _inertia;
+/// public MyController(IInertia inertia) => _inertia = inertia;
+/// public IActionResult Index() => await _inertia.Render("Page", new { });
+/// }
+///
+///
+[Obsolete("The static Inertia facade is deprecated. Inject IInertia via constructor injection instead.")]
public static class Inertia
{
private static IResponseFactory _factory = default!;
-
- internal static void UseFactory(IResponseFactory factory) => _factory = factory;
-
+ private static IHttpContextAccessor? _contextAccessor;
+
+ internal static void UseFactory(IResponseFactory factory, IHttpContextAccessor? contextAccessor = null)
+ {
+ _factory = factory;
+ _contextAccessor = contextAccessor;
+ }
+
+ private static IInertia ResolveService()
+ {
+ if (_contextAccessor?.HttpContext != null)
+ {
+ var service = _contextAccessor.HttpContext.RequestServices.GetService(typeof(IInertia));
+ if (service is IInertia inertia)
+ return inertia;
+ }
+
+ // Fallback: wrap the internal factory
+ var state = new Services.InertiaState();
+ var options = Microsoft.Extensions.Options.Options.Create(new Models.InertiaOptions());
+ return new Services.InertiaService(_factory, state, options);
+ }
+
+ ///
+ /// Render an Inertia page component.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static Response Render(string component, object? props = null) => _factory.Render(component, props);
+ ///
+ /// Render the SSR head content.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static Task Head(dynamic model) => _factory.Head(model);
+ ///
+ /// Render the SSR body / non-SSR placeholder HTML.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static Task Html(dynamic model) => _factory.Html(model);
+ ///
+ /// Set the asset version to a fixed string.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static void Version(string? version) => _factory.Version(version);
+ ///
+ /// Set the asset version via a lazily-evaluated function.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static void Version(Func version) => _factory.Version(version);
+ ///
+ /// Get the currently configured asset version.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static string? GetVersion() => _factory.GetVersion();
+ ///
+ /// Return an external redirect via Inertia Location response.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static LocationResult Location(string url) => _factory.Location(url);
+ ///
+ /// Share a value across all Inertia responses for the current request.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static void Share(string key, object? value) => _factory.Share(key, value);
+ ///
+ /// Share multiple values across all Inertia responses for the current request.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static void Share(IDictionary data) => _factory.Share(data);
+ ///
+ /// Create an AlwaysProp.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static AlwaysProp Always(string value) => _factory.Always(value);
+ ///
+ /// Create an AlwaysProp with a synchronous callback.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static AlwaysProp Always(Func callback) => _factory.Always(callback);
+ ///
+ /// Create an AlwaysProp with an asynchronous callback.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static AlwaysProp Always(Func> callback) => _factory.Always(callback);
+ ///
+ /// Create a LazyProp.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static LazyProp Lazy(Func callback) => _factory.Lazy(callback);
+ ///
+ /// Create a LazyProp with an asynchronous callback.
+ ///
+ [Obsolete("Inject IInertia via constructor injection instead.")]
public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback);
}
diff --git a/InertiaCore/InertiaCore.csproj b/InertiaCore/InertiaCore.csproj
index 5f68508..50d79b1 100644
--- a/InertiaCore/InertiaCore.csproj
+++ b/InertiaCore/InertiaCore.csproj
@@ -2,8 +2,8 @@
enable
enable
- 0.0.9
- net6.0;net7.0;net8.0;net9.0
+ 0.0.10
+ net8.0;net9.0;net10.0
AspNetCore.InertiaCore
kapi2289
Inertia.js ASP.NET Adapter. https://inertiajs.com/
@@ -22,7 +22,6 @@
-
diff --git a/InertiaCore/Models/InertiaOptions.cs b/InertiaCore/Models/InertiaOptions.cs
index cc5de44..6e6b146 100644
--- a/InertiaCore/Models/InertiaOptions.cs
+++ b/InertiaCore/Models/InertiaOptions.cs
@@ -7,4 +7,6 @@ public class InertiaOptions
public bool SsrEnabled { get; set; } = false;
public string SsrUrl { get; set; } = "http://127.0.0.1:13714/render";
public bool EncryptHistory { get; set; } = false;
+
+ public string? Version { get; set; }
}
diff --git a/InertiaCore/Props/DeferredProp.cs b/InertiaCore/Props/DeferredProp.cs
new file mode 100644
index 0000000..faf7561
--- /dev/null
+++ b/InertiaCore/Props/DeferredProp.cs
@@ -0,0 +1,25 @@
+using InertiaCore.Extensions;
+
+namespace InertiaCore.Props;
+
+///
+/// A deferred prop that is excluded from the initial full page load
+/// and resolved only when explicitly requested via X-Inertia-Partial-Data.
+/// See: https://inertiajs.com/deferred-props
+///
+public class DeferredProp : InvokableProp
+{
+ ///
+ /// Creates a deferred prop with a synchronous factory.
+ ///
+ internal DeferredProp(Func value) : base(value)
+ {
+ }
+
+ ///
+ /// Creates a deferred prop with an asynchronous factory.
+ ///
+ internal DeferredProp(Func> value) : base(value)
+ {
+ }
+}
diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs
index 7bdd2cb..1ebc132 100644
--- a/InertiaCore/Response.cs
+++ b/InertiaCore/Response.cs
@@ -1,16 +1,19 @@
using System.Text.Json;
-using System.Text.Json.Serialization;
using InertiaCore.Extensions;
using InertiaCore.Models;
using InertiaCore.Props;
+using InertiaCore.Services;
using InertiaCore.Utils;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Routing;
namespace InertiaCore;
-public class Response : IActionResult
+public class Response : IActionResult, IResult
{
private readonly string _component;
private readonly Dictionary _props;
@@ -18,19 +21,89 @@ public class Response : IActionResult
private readonly string? _version;
private readonly bool _encryptHistory;
private readonly bool _clearHistory;
+ private readonly InertiaState _state;
+ private readonly JsonSerializerOptions _jsonOptions;
private ActionContext? _context;
private Page? _page;
private IDictionary? _viewData;
- internal Response(string component, Dictionary props, string rootView, string? version, bool encryptHistory, bool clearHistory)
- => (_component, _props, _rootView, _version, _encryptHistory, _clearHistory) = (component, props, rootView, version, encryptHistory, clearHistory);
+ internal Response(
+ string component,
+ Dictionary props,
+ string rootView,
+ string? version,
+ bool encryptHistory,
+ bool clearHistory,
+ InertiaState state,
+ JsonSerializerOptions jsonOptions)
+ {
+ _component = component;
+ _props = props;
+ _rootView = rootView;
+ _version = version;
+ _encryptHistory = encryptHistory;
+ _clearHistory = clearHistory;
+ _state = state ?? throw new ArgumentNullException(nameof(state));
+ _jsonOptions = jsonOptions ?? throw new ArgumentNullException(nameof(jsonOptions));
+ }
- public async Task ExecuteResultAsync(ActionContext context)
+ ///
+ /// MVC / controller path — ActionContext is provided directly by the MVC pipeline.
+ ///
+ async Task IActionResult.ExecuteResultAsync(ActionContext context)
+ {
+ _context = context;
+ await ProcessResponse();
+ await GetResult().ExecuteResultAsync(_context);
+ }
+
+ ///
+ /// Minimal API path — wraps the HttpContext in a minimal ActionContext so the
+ /// existing processing pipeline works without modification.
+ ///
+ /// For Inertia requests we write JSON directly to the response rather than going
+ /// through , which requires
+ /// RequestServices to be populated (not guaranteed in Minimal API hosts or
+ /// unit tests using DefaultHttpContext ).
+ ///
+ ///
+ /// For non-Inertia (full page) requests we fall back to
+ /// — the view engine is always available
+ /// in a real host, and this path is never reached in unit tests since the view result
+ /// type is asserted without executing it.
+ ///
+ ///
+ async Task IResult.ExecuteAsync(HttpContext httpContext)
{
- SetContext(context);
+ var routeData = httpContext.GetRouteData() ?? new RouteData();
+ _context = new ActionContext(
+ httpContext,
+ routeData,
+ new ActionDescriptor(),
+ new ModelStateDictionary()
+ );
+
await ProcessResponse();
- await GetResult().ExecuteResultAsync(_context!);
+
+ if (_context.IsInertiaRequest())
+ {
+ // Set headers via the shared GetJson() path so protocol behaviour
+ // (X-Inertia, Vary, status 200) is identical to the MVC path.
+ var jsonResult = GetJson();
+
+ httpContext.Response.ContentType = "application/json";
+ var json = JsonSerializer.Serialize(jsonResult.Value, _jsonOptions);
+ await httpContext.Response.WriteAsync(json);
+ }
+ else
+ {
+ // Full page load — delegate to MVC ViewResult which needs the view engine.
+ // In a real Minimal API host, AddControllersWithViews / AddRazorPages will
+ // have registered the required services. Unit tests assert on GetResult()
+ // directly and never reach this branch via ExecuteAsync.
+ await GetView().ExecuteResultAsync(_context);
+ }
}
protected internal async Task ProcessResponse()
@@ -60,6 +133,7 @@ protected internal async Task ProcessResponse()
var props = _props;
props = ResolveSharedProps(props);
+ props = ResolveFlashProps(props);
props = ResolvePartialProperties(props);
props = ResolveAlways(props);
props = await ResolvePropertyInstances(props);
@@ -68,15 +142,40 @@ protected internal async Task ProcessResponse()
}
///
- /// Resolve `shared` props stored in the current request context.
+ /// Resolve `shared` props stored in InertiaState (migrated from HttpContext.Features).
///
private Dictionary ResolveSharedProps(Dictionary props)
{
- var shared = _context!.HttpContext.Features.Get();
- if (shared != null)
- props = shared.GetMerged(props);
+ if (_state.SharedProps.Count == 0)
+ return props;
- return props;
+ var result = new Dictionary(props.Count + _state.SharedProps.Count);
+ foreach (var (key, value) in _state.SharedProps)
+ result[key] = value;
+
+ foreach (var (key, value) in props)
+ result[key] = value;
+
+ return result;
+ }
+
+ ///
+ /// Resolve flash props stored in InertiaState.
+ /// Flash props are merged after shared props but before component props.
+ ///
+ private Dictionary ResolveFlashProps(Dictionary props)
+ {
+ if (_state.FlashProps.Count == 0)
+ return props;
+
+ var result = new Dictionary(props.Count + _state.FlashProps.Count);
+ foreach (var (key, value) in props)
+ result[key] = value;
+
+ foreach (var (key, value) in _state.FlashProps)
+ result[key] = value;
+
+ return result;
}
///
@@ -88,7 +187,7 @@ protected internal async Task ProcessResponse()
if (!isPartial)
return props
- .Where(kv => kv.Value is not LazyProp)
+ .Where(kv => kv.Value is not LazyProp and not DeferredProp)
.ToDictionary(kv => kv.Key, kv => kv.Value);
props = props.ToDictionary(kv => kv.Key, kv => kv.Value);
@@ -133,7 +232,8 @@ protected internal async Task ProcessResponse()
}
///
- /// Resolve `always` properties that should always be included on all visits, regardless of "only" or "except" requests.
+ /// Resolve `always` properties that should always be included on all visits,
+ /// regardless of "only" or "except" requests.
///
private Dictionary ResolveAlways(Dictionary props)
{
@@ -174,13 +274,17 @@ protected internal JsonResult GetJson()
{
_context!.HttpContext.Response.Headers.Override(InertiaHeader.Inertia, "true");
_context!.HttpContext.Response.Headers.Override("Vary", InertiaHeader.Inertia);
+
+ // Per the Inertia.js protocol, partial reload responses should include
+ // Vary: X-Inertia-Partial-Data so that CDNs differentiate between full
+ // page responses and partial reload responses for caching purposes.
+ // See: https://inertiajs.com/the-protocol#asset-versioning
+ if (_context!.IsInertiaPartialComponent(_component))
+ _context.HttpContext.Response.Headers["Vary"] += ", " + InertiaHeader.PartialOnly;
+
_context!.HttpContext.Response.StatusCode = 200;
- return new JsonResult(_page, new JsonSerializerOptions
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- ReferenceHandler = ReferenceHandler.IgnoreCycles
- });
+ return new JsonResult(_page, _jsonOptions);
}
private ViewResult GetView()
@@ -200,13 +304,35 @@ private ViewResult GetView()
protected internal IActionResult GetResult() => _context!.IsInertiaRequest() ? GetJson() : GetView();
- private Dictionary GetErrors()
+ ///
+ /// Gets validation errors from ModelState.
+ /// If the X-Inertia-Error-Bag header is present, errors are scoped under a named bag.
+ /// See: https://inertiajs.com/error-handling#error-bags
+ ///
+ private Dictionary GetErrors()
{
if (!_context!.ModelState.IsValid)
- return _context!.ModelState.ToDictionary(o => o.Key.ToCamelCase(),
+ {
+ var errors = _context!.ModelState.ToDictionary(
+ o => o.Key.ToCamelCase(),
o => o.Value?.Errors.FirstOrDefault()?.ErrorMessage ?? "");
- return new Dictionary(0);
+ // X-Inertia-Error-Bag scopes validation errors to a named bag.
+ var errorBag = _context.HttpContext.Request.Headers[InertiaHeader.ErrorBag].ToString();
+
+ if (!string.IsNullOrEmpty(errorBag))
+ {
+ return new Dictionary
+ {
+ [errorBag] = errors
+ };
+ }
+
+ return new Dictionary(errors
+ .ToDictionary(kv => kv.Key, kv => (object?)kv.Value));
+ }
+
+ return new Dictionary();
}
protected internal void SetContext(ActionContext context) => _context = context;
@@ -218,4 +344,4 @@ public Response WithViewData(IDictionary viewData)
_viewData = viewData;
return this;
}
-}
+}
\ No newline at end of file
diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs
index 534b7ba..9d28a99 100644
--- a/InertiaCore/ResponseFactory.cs
+++ b/InertiaCore/ResponseFactory.cs
@@ -3,32 +3,39 @@
using System.Text.Json.Serialization;
using InertiaCore.Models;
using InertiaCore.Props;
+using InertiaCore.Services;
using InertiaCore.Ssr;
+using InertiaCore.Extensions;
using InertiaCore.Utils;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace InertiaCore;
-internal interface IResponseFactory
+///
+/// Internal factory contract for creating Inertia responses.
+/// Consumers should prefer IInertia over this interface.
+///
+public interface IResponseFactory
{
- public Response Render(string component, object? props = null);
- public Task Head(dynamic model);
- public Task Html(dynamic model);
- public void Version(string? version);
- public void Version(Func version);
- public string? GetVersion();
- public LocationResult Location(string url);
- public void Share(string key, object? value);
- public void Share(IDictionary data);
- public void ClearHistory(bool clear = true);
- public void EncryptHistory(bool encrypt = true);
- public AlwaysProp Always(object? value);
- public AlwaysProp Always(Func callback);
- public AlwaysProp Always(Func> callback);
- public LazyProp Lazy(Func callback);
- public LazyProp Lazy(Func> callback);
+ Response Render(string component, object? props = null);
+ Task Head(object model);
+ Task Html(object model);
+ void Version(string? version);
+ void Version(Func version);
+ string? GetVersion();
+ LocationResult Location(string url);
+ void Share(string key, object? value);
+ void Share(IDictionary data);
+ void ClearHistory(bool clear = true);
+ void EncryptHistory(bool encrypt = true);
+ AlwaysProp Always(object? value);
+ AlwaysProp Always(Func callback);
+ AlwaysProp Always(Func> callback);
+ LazyProp Lazy(Func callback);
+ LazyProp Lazy(Func> callback);
}
internal class ResponseFactory : IResponseFactory
@@ -36,13 +43,33 @@ internal class ResponseFactory : IResponseFactory
private readonly IHttpContextAccessor _contextAccessor;
private readonly IGateway _gateway;
private readonly IOptions _options;
+ private readonly InertiaState _state;
+ private readonly JsonSerializerOptions _jsonOptions;
- private object? _version;
private bool _clearHistory;
private bool? _encryptHistory;
- public ResponseFactory(IHttpContextAccessor contextAccessor, IGateway gateway, IOptions options) =>
- (_contextAccessor, _gateway, _options) = (contextAccessor, gateway, options);
+ public ResponseFactory(
+ IHttpContextAccessor contextAccessor,
+ IGateway gateway,
+ IOptions options,
+ InertiaState state,
+ IOptions jsonOptions)
+ {
+ _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
+ _gateway = gateway ?? throw new ArgumentNullException(nameof(gateway));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _state = state ?? throw new ArgumentNullException(nameof(state));
+
+ // Start from the application's configured JsonSerializerOptions, then ensure
+ // camelCase and cycle-handling defaults that Inertia.js requires.
+ JsonSerializerOptions hostOptions = jsonOptions?.Value?.JsonSerializerOptions ?? new JsonSerializerOptions();
+ _jsonOptions = new JsonSerializerOptions(hostOptions)
+ {
+ PropertyNamingPolicy = hostOptions.PropertyNamingPolicy ?? JsonNamingPolicy.CamelCase,
+ ReferenceHandler = hostOptions.ReferenceHandler ?? ReferenceHandler.IgnoreCycles
+ };
+ }
public Response Render(string component, object? props = null)
{
@@ -50,89 +77,90 @@ public Response Render(string component, object? props = null)
var dictProps = props switch
{
Dictionary dict => dict,
- _ => props.GetType().GetProperties()
- .ToDictionary(o => o.Name, o => o.GetValue(props))
+ _ => SerializePropsToDictionary(props)
};
- return new Response(component, dictProps, _options.Value.RootView, GetVersion(), _encryptHistory ?? _options.Value.EncryptHistory, _clearHistory);
+ return new Response(
+ component,
+ dictProps,
+ _options.Value.RootView,
+ GetVersion(),
+ _encryptHistory ?? _options.Value.EncryptHistory,
+ _clearHistory,
+ _state,
+ _jsonOptions);
}
+
+ public IResult RenderResult(string component, object? props = null)
+ => (IResult)Render(component, props);
- public async Task Head(dynamic model)
+ public async Task Head(object model)
{
- if (!_options.Value.SsrEnabled) return new HtmlString("");
+ if (!_options.Value.SsrEnabled)
+ return new HtmlString("");
var context = _contextAccessor.HttpContext!;
- var response = context.Features.Get();
+ // Migrated from HttpContext.Features — see InertiaState
+ var response = _state.SsrResponse;
response ??= await _gateway.Dispatch(model, _options.Value.SsrUrl);
- if (response == null) return new HtmlString("");
+ if (response == null)
+ return new HtmlString("");
- context.Features.Set(response);
+ _state.SsrResponse = response;
return response.GetHead();
}
- public async Task Html(dynamic model)
+ public async Task Html(object model)
{
if (_options.Value.SsrEnabled)
{
var context = _contextAccessor.HttpContext!;
- var response = context.Features.Get();
+ // Migrated from HttpContext.Features — see InertiaState
+ var response = _state.SsrResponse;
response ??= await _gateway.Dispatch(model, _options.Value.SsrUrl);
if (response != null)
{
- context.Features.Set(response);
+ _state.SsrResponse = response;
return response.GetBody();
}
}
- var data = JsonSerializer.Serialize(model,
- new JsonSerializerOptions
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- ReferenceHandler = ReferenceHandler.IgnoreCycles
- });
-
- var encoded = WebUtility.HtmlEncode(data);
+ var data = JsonSerializer.Serialize(model, _jsonOptions);
+ // v3 protocol: escape forward slashes to avoid premature termination
+ var escaped = data.Replace("/", "\\/");
- return new HtmlString($"
");
+ return new HtmlString(
+ $"\n" +
+ $"
"
+ );
}
- public void Version(string? version) => _version = version;
+ public void Version(string? version) => _state.Version = version;
- public void Version(Func version) => _version = version;
+ public void Version(Func version) => _state.VersionFunc = version;
- public string? GetVersion() => _version switch
- {
- Func func => func.Invoke(),
- string s => s,
- _ => null,
- };
+ public string? GetVersion() =>
+ _state.VersionFunc is not null
+ ? _state.VersionFunc.Invoke()
+ : _state.Version;
public LocationResult Location(string url) => new(url);
public void Share(string key, object? value)
{
- var context = _contextAccessor.HttpContext!;
-
- var sharedData = context.Features.Get();
- sharedData ??= new InertiaSharedProps();
- sharedData.Set(key, value);
-
- context.Features.Set(sharedData);
+ _state.SharedProps[key.ToCamelCase()] = value;
}
public void Share(IDictionary data)
{
- var context = _contextAccessor.HttpContext!;
-
- var sharedData = context.Features.Get();
- sharedData ??= new InertiaSharedProps();
- sharedData.Merge(data);
-
- context.Features.Set(sharedData);
+ foreach (var (key, value) in data)
+ {
+ _state.SharedProps[key.ToCamelCase()] = value;
+ }
}
public void ClearHistory(bool clear = true) => _clearHistory = clear;
@@ -144,4 +172,69 @@ public void Share(IDictionary data)
public AlwaysProp Always(object? value) => new(value);
public AlwaysProp Always(Func callback) => new(callback);
public AlwaysProp Always(Func> callback) => new(callback);
+
+ ///
+ /// Converts an arbitrary props object to a dictionary.
+ /// Prefers JSON round-trip for AOT compatibility, with reflection fallback
+ /// for objects that contain delegates or other non-serializable members.
+ ///
+ private Dictionary SerializePropsToDictionary(object props)
+ {
+ if (props is IDictionary dict)
+ return new Dictionary(dict);
+
+ var reflectedProps = props.GetType().GetProperties();
+
+ // If any property value is an InvokableProp (DeferredProp, LazyProp, AlwaysProp),
+ // the JSON round-trip path cannot be used because it serializes these as {}
+ // and destroys type information needed for downstream type checks.
+ if (reflectedProps.Any(p => p.GetValue(props) is InvokableProp))
+ return reflectedProps.ToDictionary(o => o.Name, o => o.GetValue(props));
+
+ // First attempt: JSON round-trip — AOT-compatible and respects serializer options.
+ try
+ {
+ var json = JsonSerializer.Serialize(props, _jsonOptions);
+ var document = JsonSerializer.Deserialize(json, _jsonOptions);
+
+ if (document != null)
+ return JsonElementToDictionary(document.RootElement);
+ }
+ catch (NotSupportedException)
+ {
+ // JSON serialization of delegates is not supported.
+ // Fall through to reflection-based conversion below.
+ }
+
+ // Fallback: reflection-based property enumeration for objects containing delegates
+ // or other types that cannot be JSON-serialized (e.g. Func<>, Task, LazyProp).
+ return reflectedProps.ToDictionary(o => o.Name, o => o.GetValue(props));
+ }
+
+ private static Dictionary JsonElementToDictionary(JsonElement element)
+ {
+ var result = new Dictionary();
+
+ foreach (var property in element.EnumerateObject())
+ {
+ result[property.Name] = JsonElementToValue(property.Value);
+ }
+
+ return result;
+ }
+
+ private static object? JsonElementToValue(JsonElement element)
+ {
+ return element.ValueKind switch
+ {
+ JsonValueKind.Object => JsonElementToDictionary(element),
+ JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToValue).ToList(),
+ JsonValueKind.String => element.GetString(),
+ JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.Null => null,
+ _ => element.GetRawText()
+ };
+ }
}
diff --git a/InertiaCore/Services/InertiaService.cs b/InertiaCore/Services/InertiaService.cs
new file mode 100644
index 0000000..c4ddb4b
--- /dev/null
+++ b/InertiaCore/Services/InertiaService.cs
@@ -0,0 +1,88 @@
+using InertiaCore.Contracts;
+using InertiaCore.Extensions;
+using InertiaCore.Models;
+using InertiaCore.Props;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace InertiaCore.Services;
+
+///
+/// Scoped implementation of that delegates all operations
+/// to the internal and .
+///
+public sealed class InertiaService : IInertia
+{
+ private readonly IResponseFactory _factory;
+ private readonly InertiaState _state;
+ private readonly IOptions _options;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ public InertiaService(IResponseFactory factory, InertiaState state, IOptions options)
+ {
+ _factory = factory ?? throw new ArgumentNullException(nameof(factory));
+ _state = state ?? throw new ArgumentNullException(nameof(state));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ ///
+ public Task Render(string component, object? props = null)
+ {
+ Response response = _factory.Render(component, props);
+ return Task.FromResult(response);
+ }
+
+ ///
+ public IActionResult Location(string url) => _factory.Location(url);
+
+ ///
+ public void Share(string key, object? value) => _factory.Share(key, value);
+
+ ///
+ public void Share(IDictionary data) => _factory.Share(data);
+
+ ///
+ public void Flash(string key, object? value)
+ {
+ _state.FlashProps[key.ToCamelCase()] = value;
+ }
+
+ ///
+ public void Version(string? version) => _factory.Version(version);
+
+ ///
+ public void Version(Func versionFunc) => _factory.Version(versionFunc);
+
+ ///
+ public string? GetVersion() => _factory.GetVersion();
+
+ ///
+ public AlwaysProp Always(object? value) => _factory.Always(value);
+
+ ///
+ public AlwaysProp Always(Func callback) => _factory.Always(callback);
+
+ ///
+ public AlwaysProp Always(Func> callback) => _factory.Always(callback);
+
+ ///
+ public LazyProp Lazy(Func callback) => _factory.Lazy(callback);
+
+ ///
+ public LazyProp Lazy(Func> callback) => _factory.Lazy(callback);
+
+ ///
+ public DeferredProp Defer(Func factory) => new(factory);
+
+ ///
+ public DeferredProp Defer(Func> factory) => new(factory);
+
+ ///
+ public void EncryptHistory(bool encrypt = true) => _factory.EncryptHistory(encrypt);
+
+ ///
+ public void ClearHistory(bool clear = true) => _factory.ClearHistory(clear);
+
+}
diff --git a/InertiaCore/Services/InertiaState.cs b/InertiaCore/Services/InertiaState.cs
new file mode 100644
index 0000000..d567395
--- /dev/null
+++ b/InertiaCore/Services/InertiaState.cs
@@ -0,0 +1,38 @@
+using InertiaCore.Ssr;
+
+namespace InertiaCore.Services;
+
+///
+/// Per-request state container for Inertia.js operations.
+/// Registered as Scoped — one instance per HTTP request.
+///
+public sealed class InertiaState
+{
+ ///
+ /// Shared props set via IInertia.Share() .
+ /// Persisted for the entire request lifetime.
+ ///
+ public Dictionary SharedProps { get; } = new();
+
+ ///
+ /// Flash props set via IInertia.Flash() .
+ /// Available only for the current request (single-request scope).
+ ///
+ public Dictionary FlashProps { get; } = new();
+
+ ///
+ /// Fixed asset version string.
+ ///
+ public string? Version { get; set; }
+
+ ///
+ /// Lazy asset version factory.
+ ///
+ public Func? VersionFunc { get; set; }
+
+ ///
+ /// Cached SSR response, set after the first SSR dispatch in a request.
+ /// Migrated from HttpContext.Features — see InertiaState.
+ ///
+ public SsrResponse? SsrResponse { get; set; }
+}
diff --git a/InertiaCore/Ssr/Gateway.cs b/InertiaCore/Ssr/Gateway.cs
index 1878c52..b64bd5e 100644
--- a/InertiaCore/Ssr/Gateway.cs
+++ b/InertiaCore/Ssr/Gateway.cs
@@ -2,6 +2,8 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
namespace InertiaCore.Ssr;
@@ -13,21 +15,32 @@ internal interface IGateway
internal class Gateway : IGateway
{
private readonly IHttpClientFactory _httpClientFactory;
+ private readonly JsonSerializerOptions _jsonOptions;
- public Gateway(IHttpClientFactory httpClientFactory) => _httpClientFactory = httpClientFactory;
+ public Gateway(IHttpClientFactory httpClientFactory)
+ : this(httpClientFactory, null)
+ {
+ }
+
+ public Gateway(IHttpClientFactory httpClientFactory, IOptions? jsonOptions)
+ {
+ _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
+
+ JsonSerializerOptions hostOptions = jsonOptions?.Value?.JsonSerializerOptions ?? new JsonSerializerOptions();
+ _jsonOptions = new JsonSerializerOptions(hostOptions)
+ {
+ PropertyNamingPolicy = hostOptions.PropertyNamingPolicy ?? JsonNamingPolicy.CamelCase,
+ ReferenceHandler = hostOptions.ReferenceHandler ?? ReferenceHandler.IgnoreCycles
+ };
+ }
- public async Task Dispatch(dynamic model, string url)
+ public async Task Dispatch(object model, string url)
{
- var json = JsonSerializer.Serialize(model,
- new JsonSerializerOptions
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- ReferenceHandler = ReferenceHandler.IgnoreCycles
- });
+ var json = JsonSerializer.Serialize(model, _jsonOptions);
var content = new StringContent(json.ToString(), Encoding.UTF8, "application/json");
var client = _httpClientFactory.CreateClient();
var response = await client.PostAsync(url, content);
- return await response.Content.ReadFromJsonAsync();
+ return await response.Content.ReadFromJsonAsync(_jsonOptions);
}
}
diff --git a/InertiaCore/Ssr/SsrResponse.cs b/InertiaCore/Ssr/SsrResponse.cs
index b5c8094..e859dfc 100644
--- a/InertiaCore/Ssr/SsrResponse.cs
+++ b/InertiaCore/Ssr/SsrResponse.cs
@@ -2,7 +2,7 @@
namespace InertiaCore.Ssr;
-internal class SsrResponse
+public class SsrResponse
{
public List Head { get; set; } = default!;
public string Body { get; set; } = default!;
diff --git a/InertiaCore/Utils/InertiaSharedProps.cs b/InertiaCore/Utils/InertiaSharedProps.cs
index 68b9bbd..b867bd2 100644
--- a/InertiaCore/Utils/InertiaSharedProps.cs
+++ b/InertiaCore/Utils/InertiaSharedProps.cs
@@ -2,6 +2,13 @@
namespace InertiaCore.Utils;
+///
+/// Stores shared Inertia props per-request.
+///
+/// This class is deprecated. Use InertiaCore.Services.InertiaState instead.
+///
+///
+[Obsolete("Use InertiaCore.Services.InertiaState instead.")]
internal class InertiaSharedProps
{
private IDictionary? Data { get; set; }
diff --git a/InertiaCore/Utils/LocationResult.cs b/InertiaCore/Utils/LocationResult.cs
index 03de9e2..97ab916 100644
--- a/InertiaCore/Utils/LocationResult.cs
+++ b/InertiaCore/Utils/LocationResult.cs
@@ -1,24 +1,31 @@
using System.Net;
using InertiaCore.Extensions;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace InertiaCore.Utils;
-public class LocationResult : IActionResult
+public class LocationResult : IActionResult, IResult
{
private readonly string _url;
-
public LocationResult(string url) => _url = url;
- public async Task ExecuteResultAsync(ActionContext context)
+ public Task ExecuteResultAsync(ActionContext context)
+ {
+ var response = context.HttpContext.Response;
+ SetHeaders(response);
+ return Task.CompletedTask;
+ }
+
+ public Task ExecuteAsync(HttpContext httpContext)
{
- if (context.IsInertiaRequest())
- {
- context.HttpContext.Response.Headers.Override(InertiaHeader.Location, _url);
- await new StatusCodeResult((int)HttpStatusCode.Conflict).ExecuteResultAsync(context);
- return;
- }
+ SetHeaders(httpContext.Response);
+ return Task.CompletedTask;
+ }
- await new RedirectResult(_url).ExecuteResultAsync(context);
+ private void SetHeaders(HttpResponse response)
+ {
+ response.Headers["X-Inertia-Location"] = _url;
+ response.StatusCode = 409;
}
}
diff --git a/InertiaCore/Utils/TaskExtensions.cs b/InertiaCore/Utils/TaskExtensions.cs
new file mode 100644
index 0000000..dd3b601
--- /dev/null
+++ b/InertiaCore/Utils/TaskExtensions.cs
@@ -0,0 +1,45 @@
+namespace InertiaCore.Utils;
+
+///
+/// Safe task unwrapping utilities for resolving Inertia props.
+///
+internal static class TaskExtensions
+{
+ ///
+ /// Safely awaits a task and returns its result as ,
+ /// or if the task has no result.
+ ///
+ ///
+ /// This method awaits the task first, then reads Result via reflection.
+ /// Because the task has already completed, the reflection call is safe and
+ /// will not block. This is the single acceptable use of reflection in the
+ /// library for task unwrapping.
+ ///
+ internal static async Task UnwrapResultAsync(this Task task)
+ {
+ await task.ConfigureAwait(false);
+
+ Type taskType = task.GetType();
+
+ // Non-generic Task has no result value.
+ if (!taskType.IsGenericType)
+ return null;
+
+ // Use GetProperty("Result") rather than GetGenericTypeDefinition() == typeof(Task<>)
+ // to handle compiler-generated async state machine types that inherit Task{T}
+ // but fail the generic type definition equality check.
+ var resultProperty = taskType.GetProperty("Result");
+
+ if (resultProperty is null)
+ return null;
+
+ // Guard: if the Result property's type is VoidTaskResult (an internal struct used
+ // by AsyncTaskMethodBuilder for non-generic Task completion), return null since
+ // there is no meaningful result value.
+ if (resultProperty.PropertyType.Name == "VoidTaskResult")
+ return null;
+
+ // Safe: task has already been awaited, so Result will not block.
+ return resultProperty.GetValue(task);
+ }
+}
diff --git a/InertiaCore/Utils/Vite.cs b/InertiaCore/Utils/Vite.cs
index 8e294c4..52ee973 100644
--- a/InertiaCore/Utils/Vite.cs
+++ b/InertiaCore/Utils/Vite.cs
@@ -21,6 +21,10 @@ internal class ViteBuilder : IViteBuilder
{
private IFileSystem _fileSystem;
private readonly IOptions _options;
+ private readonly object _cacheLock = new();
+ private string? _cachedManifest;
+ private DateTime _lastManifestRead = DateTime.MinValue;
+ private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30);
public ViteBuilder(IOptions options) => (_fileSystem, _options) = (new FileSystem(), options);
@@ -29,9 +33,6 @@ protected internal void UseFileSystem(IFileSystem fileSystem)
_fileSystem = fileSystem;
}
- ///
- /// Get the public directory
- ///
private string GetPublicPathForFile(string path)
{
var pieces = new List {
@@ -42,9 +43,6 @@ private string GetPublicPathForFile(string path)
return string.Join("/", pieces);
}
- ///
- /// Get the public directory and build path.
- ///
private string GetBuildPathForFile(string path)
{
var pieces = new List { _options.Value.PublicDirectory };
@@ -60,6 +58,7 @@ private string GetBuildPathForFile(string path)
///
/// Generates various tags from a given input file path.
///
+ [Obsolete("Use InputAsync instead. This method uses synchronous file I/O.")]
public HtmlString Input(string path)
{
if (IsRunningHot())
@@ -73,6 +72,83 @@ public HtmlString Input(string path)
}
var manifest = _fileSystem.File.ReadAllText(GetBuildPathForFile(_options.Value.ManifestFilename));
+ return ProcessManifest(path, manifest);
+ }
+
+ ///
+ /// Asynchronously generates various tags from a given input file path.
+ /// Uses file I/O abstraction and caching for production builds.
+ ///
+ public async Task InputAsync(string path, CancellationToken cancellationToken = default)
+ {
+ if (IsRunningHot())
+ {
+ return new HtmlString(MakeModuleTag("@vite/client").Value + MakeModuleTag(path).Value);
+ }
+
+ var manifestPath = GetBuildPathForFile(_options.Value.ManifestFilename);
+
+ // Check cache before reading
+ if (!_fileSystem.File.Exists(manifestPath))
+ {
+ throw new Exception("Vite Manifest is missing. Run `npm run build` and try again.");
+ }
+
+ var manifest = await ReadManifestWithCacheAsync(manifestPath, cancellationToken);
+ return ProcessManifest(path, manifest);
+ }
+
+ ///
+ /// Generate React refresh runtime script.
+ ///
+ [Obsolete("Use ReactRefreshAsync instead. This method uses synchronous file I/O.")]
+ public HtmlString ReactRefresh()
+ {
+ if (!IsRunningHot())
+ {
+ return new HtmlString("");
+ }
+
+ var builder = new TagBuilder("script");
+ builder.Attributes.Add("type", "module");
+
+ var inner = $"import RefreshRuntime from '{Asset("@react-refresh")}';" +
+ "RefreshRuntime.injectIntoGlobalHook(window);" +
+ "window.$RefreshReg$ = () => { };" +
+ "window.$RefreshSig$ = () => (type) => type;" +
+ "window.__vite_plugin_react_preamble_installed__ = true;";
+
+ builder.InnerHtml.AppendHtml(inner);
+
+ return new HtmlString(GetString(builder));
+ }
+
+ ///
+ /// Asynchronously generate React refresh runtime script.
+ ///
+ public Task ReactRefreshAsync(CancellationToken cancellationToken = default)
+ {
+ if (!IsRunningHot())
+ {
+ return Task.FromResult(new HtmlString(""));
+ }
+
+ var builder = new TagBuilder("script");
+ builder.Attributes.Add("type", "module");
+
+ var inner = $"import RefreshRuntime from '{Asset("@react-refresh")}';" +
+ "RefreshRuntime.injectIntoGlobalHook(window);" +
+ "window.$RefreshReg$ = () => { };" +
+ "window.$RefreshSig$ = () => (type) => type;" +
+ "window.__vite_plugin_react_preamble_installed__ = true;";
+
+ builder.InnerHtml.AppendHtml(inner);
+
+ return Task.FromResult(new HtmlString(GetString(builder)));
+ }
+
+ private HtmlString ProcessManifest(string path, string manifest)
+ {
var manifestJson = JsonSerializer.Deserialize>(manifest);
if (manifestJson == null)
@@ -108,9 +184,30 @@ public HtmlString Input(string path)
return html;
}
- ///
- /// Generate script tag with type="module"
- ///
+ private async Task ReadManifestWithCacheAsync(string manifestPath, CancellationToken cancellationToken)
+ {
+ // Simple time-based cache to avoid reading the manifest file on every request.
+ var now = DateTime.UtcNow;
+ lock (_cacheLock)
+ {
+ if (_cachedManifest != null && now - _lastManifestRead < CacheDuration)
+ {
+ return _cachedManifest;
+ }
+ }
+
+ // Read with async file I/O via the IFileSystem abstraction.
+ var manifest = await Task.Run(() => _fileSystem.File.ReadAllText(manifestPath), cancellationToken);
+
+ lock (_cacheLock)
+ {
+ _cachedManifest = manifest;
+ _lastManifestRead = now;
+ }
+
+ return manifest;
+ }
+
private HtmlString MakeModuleTag(string path)
{
var builder = new TagBuilder("script");
@@ -120,17 +217,11 @@ private HtmlString MakeModuleTag(string path)
return new HtmlString(GetString(builder) + "\n\t");
}
- ///
- /// Generate an appropriate tag for the given URL in HMR mode.
- ///
private HtmlString MakeTag(string url)
{
return IsCssPath(url) ? MakeStylesheetTag(url) : MakeModuleTag(url);
}
- ///
- /// Generate a stylesheet tag for the given URL in HMR mode.
- ///
private HtmlString MakeStylesheetTag(string filePath)
{
var builder = new TagBuilder("link");
@@ -139,41 +230,11 @@ private HtmlString MakeStylesheetTag(string filePath)
return new HtmlString(GetString(builder).Replace(">", " />") + "\n\t");
}
- ///
- /// Determine whether the given path is a CSS file.
- ///
private static bool IsCssPath(string path)
{
return Regex.IsMatch(path, @".\.(css|less|sass|scss|styl|stylus|pcss|postcss)", RegexOptions.IgnoreCase);
}
- ///
- /// Generate React refresh runtime script.
- ///
- public HtmlString ReactRefresh()
- {
- if (!IsRunningHot())
- {
- return new HtmlString("");
- }
-
- var builder = new TagBuilder("script");
- builder.Attributes.Add("type", "module");
-
- var inner = $"import RefreshRuntime from '{Asset("@react-refresh")}';" +
- "RefreshRuntime.injectIntoGlobalHook(window);" +
- "window.$RefreshReg$ = () => { };" +
- "window.$RefreshSig$ = () => (type) => type;" +
- "window.__vite_plugin_react_preamble_installed__ = true;";
-
- builder.InnerHtml.AppendHtml(inner);
-
- return new HtmlString(GetString(builder));
- }
-
- ///
- /// Get the URL to a given asset when running in HMR mode.
- ///
private string HotAsset(string path)
{
var hotFilePath = GetPublicPathForFile(_options.Value.HotFile);
@@ -182,9 +243,6 @@ private string HotAsset(string path)
return hotContents + "/" + path;
}
- ///
- /// Get the URL for an asset.
- ///
private string Asset(string path)
{
if (IsRunningHot())
@@ -202,17 +260,11 @@ private string Asset(string path)
return "/" + string.Join("/", pieces);
}
- ///
- /// Determine if Vite is running in HMR mode.
- ///
private bool IsRunningHot()
{
return _fileSystem.File.Exists(GetPublicPathForFile(_options.Value.HotFile));
}
- ///
- /// Convert an IHtmlContent to a string.
- ///
private static string GetString(IHtmlContent content)
{
var writer = new StringWriter();
@@ -220,6 +272,15 @@ private static string GetString(IHtmlContent content)
return writer.ToString();
}
+ internal void ClearManifestCache()
+ {
+ lock (_cacheLock)
+ {
+ _cachedManifest = null;
+ _lastManifestRead = DateTime.MinValue;
+ }
+ }
+
public string? GetManifest()
{
return _fileSystem.File.Exists(GetBuildPathForFile(_options.Value.ManifestFilename))
diff --git a/InertiaCoreTests/InertiaCoreTests.csproj b/InertiaCoreTests/InertiaCoreTests.csproj
index 328dafe..9e6e294 100644
--- a/InertiaCoreTests/InertiaCoreTests.csproj
+++ b/InertiaCoreTests/InertiaCoreTests.csproj
@@ -1,7 +1,7 @@
- net6.0;net7.0;net8.0;net9.0
+ net8.0;net9.0
enable
enable
@@ -14,7 +14,10 @@
-
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/InertiaCoreTests/Setup.cs b/InertiaCoreTests/Setup.cs
index 5942c2b..75fb5e6 100644
--- a/InertiaCoreTests/Setup.cs
+++ b/InertiaCoreTests/Setup.cs
@@ -1,7 +1,8 @@
using InertiaCore;
+using InertiaCore.Extensions;
using InertiaCore.Models;
+using InertiaCore.Services;
using InertiaCore.Ssr;
-using InertiaCore.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
@@ -26,16 +27,25 @@ public void Setup()
var options = new Mock>();
options.SetupGet(x => x.Value).Returns(new InertiaOptions());
- _factory = new ResponseFactory(contextAccessor.Object, gateway, options.Object);
+ var state = new InertiaState();
+
+ var jsonOptions = new Mock>();
+ jsonOptions.SetupGet(x => x.Value).Returns(new JsonOptions());
+
+ _factory = new ResponseFactory(
+ contextAccessor.Object,
+ gateway,
+ options.Object,
+ state,
+ jsonOptions.Object);
}
///
/// Prepares ActionContext for usage in tests.
///
/// Optional request headers.
- /// Optional Inertia shared data.
/// Optional validation errors dictionary.
- private static ActionContext PrepareContext(HeaderDictionary? headers = null, InertiaSharedProps? sharedProps = null,
+ private static ActionContext PrepareContext(HeaderDictionary? headers = null,
Dictionary? modelState = null)
{
var request = new Mock();
@@ -43,10 +53,11 @@ private static ActionContext PrepareContext(HeaderDictionary? headers = null, In
var response = new Mock();
response.SetupGet(r => r.Headers).Returns(new HeaderDictionary());
+ var statusCode = 200;
+ response.SetupGet(r => r.StatusCode).Returns(() => statusCode);
+ response.SetupSet(r => r.StatusCode = It.IsAny()).Callback(v => statusCode = v);
var features = new FeatureCollection();
- if (sharedProps != null)
- features.Set(sharedProps);
var httpContext = new Mock();
httpContext.SetupGet(c => c.Request).Returns(request.Object);
diff --git a/InertiaCoreTests/UnitTestConfiguration.cs b/InertiaCoreTests/UnitTestConfiguration.cs
index 5cf8581..3aab6a2 100644
--- a/InertiaCoreTests/UnitTestConfiguration.cs
+++ b/InertiaCoreTests/UnitTestConfiguration.cs
@@ -5,6 +5,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace InertiaCoreTests;
@@ -15,8 +16,6 @@ public partial class Tests
[Description("Test if the configuration registers properly necessary services and filters.")]
public void TestConfiguration()
{
- Assert.Throws(() => Inertia.GetVersion());
-
var builder = WebApplication.CreateBuilder();
Assert.DoesNotThrow(() => builder.Services.AddInertia());
@@ -43,6 +42,8 @@ public void TestConfiguration()
var app = builder.Build();
Assert.DoesNotThrow(() => app.UseInertia());
- Assert.DoesNotThrow(() => Inertia.GetVersion());
+ // Verify IResponseFactory resolves from DI and GetVersion() works after pipeline is configured.
+ var factory = app.Services.GetRequiredService();
+ Assert.DoesNotThrow(() => factory.GetVersion());
}
}
diff --git a/InertiaCoreTests/UnitTestDeferredProps.cs b/InertiaCoreTests/UnitTestDeferredProps.cs
new file mode 100644
index 0000000..21aab3f
--- /dev/null
+++ b/InertiaCoreTests/UnitTestDeferredProps.cs
@@ -0,0 +1,151 @@
+using InertiaCore.Models;
+using InertiaCore.Props;
+using Microsoft.AspNetCore.Http;
+
+namespace InertiaCoreTests;
+
+public partial class Tests
+{
+ [Test]
+ [Description("Deferred prop is excluded from full page load response.")]
+ public async Task TestDeferredPropExcludedFromFullLoad()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test",
+ TestDeferred = new DeferredProp(() => "Deferred Value")
+ });
+
+ var context = PrepareContext();
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.That(page?.Props, Is.EqualTo(new Dictionary
+ {
+ { "test", "Test" },
+ { "errors", new Dictionary() }
+ }));
+ }
+
+ [Test]
+ [Description("Deferred prop is included in partial reload when key is requested.")]
+ public async Task TestDeferredPropIncludedOnPartial()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test",
+ TestDeferred = new DeferredProp(() => "Deferred Value")
+ });
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia-Partial-Data", "testDeferred" },
+ { "X-Inertia-Partial-Component", "Test/Page" }
+ };
+
+ var context = PrepareContext(headers);
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ // Per the Inertia.js protocol, when X-Inertia-Partial-Data specifies only
+ // "testDeferred", only that key (plus errors) should be in the response.
+ // "test" was not requested so it must not appear.
+ Assert.That(page?.Props, Is.EqualTo(new Dictionary
+ {
+ { "testDeferred", "Deferred Value" },
+ { "errors", new Dictionary() }
+ }));
+ }
+
+ [Test]
+ [Description("Deferred prop with async factory resolves correctly.")]
+ public async Task TestDeferredPropAsync()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ TestDeferred = new DeferredProp(() => Task.FromResult("Async Deferred"))
+ });
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia-Partial-Data", "testDeferred" },
+ { "X-Inertia-Partial-Component", "Test/Page" }
+ };
+
+ var context = PrepareContext(headers);
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.That(page?.Props, Is.EqualTo(new Dictionary
+ {
+ { "testDeferred", "Async Deferred" },
+ { "errors", new Dictionary() }
+ }));
+ }
+
+ [Test]
+ [Description("Multiple deferred props — only requested ones are resolved.")]
+ public async Task TestMultipleDeferredOnlyRequestedResolved()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test",
+ DeferredA = new DeferredProp(() => "Value A"),
+ DeferredB = new DeferredProp(() => "Value B")
+ });
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia-Partial-Data", "deferredA" },
+ { "X-Inertia-Partial-Component", "Test/Page" }
+ };
+
+ var context = PrepareContext(headers);
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(page?.Props.ContainsKey("deferredA"), Is.True);
+ Assert.That(page?.Props.ContainsKey("deferredB"), Is.False);
+ Assert.That(page?.Props["deferredA"], Is.EqualTo("Value A"));
+ });
+ }
+
+ [Test]
+ [Description("Vary header should be present for partial responses with deferred props.")]
+ public async Task TestVaryHeaderOnPartialWithDeferred()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ TestDeferred = new DeferredProp(() => "Value")
+ });
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia-Partial-Data", "testDeferred" },
+ { "X-Inertia-Partial-Component", "Test/Page" }
+ };
+
+ var context = PrepareContext(headers);
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var jsonResult = response.GetJson();
+
+ Assert.That(jsonResult, Is.Not.Null);
+ }
+}
diff --git a/InertiaCoreTests/UnitTestErrorBag.cs b/InertiaCoreTests/UnitTestErrorBag.cs
new file mode 100644
index 0000000..2781ae4
--- /dev/null
+++ b/InertiaCoreTests/UnitTestErrorBag.cs
@@ -0,0 +1,145 @@
+using InertiaCore.Models;
+using Microsoft.AspNetCore.Http;
+
+namespace InertiaCoreTests;
+
+public partial class Tests
+{
+ [Test]
+ [Description("Request without X-Inertia-Error-Bag returns flat errors.")]
+ public async Task TestErrorsWithoutErrorBag()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test"
+ });
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" }
+ };
+
+ var context = PrepareContext(headers, new Dictionary
+ {
+ { "Field", "Error" }
+ });
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.That(page?.Props, Is.EqualTo(new Dictionary
+ {
+ { "test", "Test" },
+ {
+ "errors", new Dictionary
+ {
+ { "field", "Error" }
+ }
+ }
+ }));
+ }
+
+ [Test]
+ [Description("Request with X-Inertia-Error-Bag scopes errors under the bag name.")]
+ public async Task TestErrorsWithNamedErrorBag()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test"
+ });
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Error-Bag", "myForm" }
+ };
+
+ var context = PrepareContext(headers, new Dictionary
+ {
+ { "Field", "Error" }
+ });
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.That(page?.Props, Is.EqualTo(new Dictionary
+ {
+ { "test", "Test" },
+ {
+ "errors", new Dictionary
+ {
+ {
+ "myForm", new Dictionary
+ {
+ { "field", "Error" }
+ }
+ }
+ }
+ }
+ }));
+ }
+
+ [Test]
+ [Description("Error bag with empty ModelState returns empty errors.")]
+ public async Task TestErrorBagWithEmptyModelState()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test"
+ });
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Error-Bag", "myForm" }
+ };
+
+ var context = PrepareContext(headers);
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.That(page?.Props, Is.EqualTo(new Dictionary
+ {
+ { "test", "Test" },
+ { "errors", new Dictionary() }
+ }));
+ }
+
+ [Test]
+ [Description("Error bag name is preserved as provided (case-sensitive per protocol).")]
+ public async Task TestErrorBagCaseSensitivity()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test"
+ });
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Error-Bag", "MyForm" }
+ };
+
+ var context = PrepareContext(headers, new Dictionary
+ {
+ { "Field", "Error" }
+ });
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ var errors = page?.Props["errors"] as Dictionary;
+ Assert.That(errors, Is.Not.Null);
+ Assert.That(errors!.ContainsKey("MyForm"), Is.True);
+ Assert.That(errors.ContainsKey("myForm"), Is.False);
+ }
+}
diff --git a/InertiaCoreTests/UnitTestFlashProps.cs b/InertiaCoreTests/UnitTestFlashProps.cs
new file mode 100644
index 0000000..ad58e24
--- /dev/null
+++ b/InertiaCoreTests/UnitTestFlashProps.cs
@@ -0,0 +1,104 @@
+using InertiaCore.Models;
+using InertiaCore.Services;
+
+namespace InertiaCoreTests;
+
+public partial class Tests
+{
+ [Test]
+ [Description("Flash prop appears in the response for the current request.")]
+ public async Task TestFlashPropAppearsInResponse()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test"
+ });
+
+ _factory.Share("flashMessage", "This is a flash");
+
+ var context = PrepareContext();
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.That(page?.Props, Is.EqualTo(new Dictionary
+ {
+ { "test", "Test" },
+ { "flashMessage", "This is a flash" },
+ { "errors", new Dictionary() }
+ }));
+ }
+
+ [Test]
+ [Description("Flash prop is overridden by component prop with the same key.")]
+ public async Task TestComponentPropOverridesFlash()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Key = "Component Value"
+ });
+
+ _factory.Share("Key", "Flash Value");
+
+ var context = PrepareContext();
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.That(page?.Props["key"], Is.EqualTo("Component Value"));
+ }
+
+ [Test]
+ [Description("Multiple flash props are all included.")]
+ public async Task TestMultipleFlashProps()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test"
+ });
+
+ _factory.Share("Flash1", "Value1");
+ _factory.Share("Flash2", "Value2");
+
+ var context = PrepareContext();
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.That(page?.Props, Is.EqualTo(new Dictionary
+ {
+ { "test", "Test" },
+ { "flash1", "Value1" },
+ { "flash2", "Value2" },
+ { "errors", new Dictionary() }
+ }));
+ }
+
+ [Test]
+ [Description("Null flash value is preserved in the response.")]
+ public async Task TestNullFlashValue()
+ {
+ var response = _factory.Render("Test/Page", new
+ {
+ Test = "Test"
+ });
+
+ _factory.Share("NullKey", null);
+
+ var context = PrepareContext();
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = response.GetJson().Value as Page;
+
+ Assert.That(page?.Props.ContainsKey("nullKey"), Is.True);
+ Assert.That(page?.Props["nullKey"], Is.Null);
+ }
+}
diff --git a/InertiaCoreTests/UnitTestInertiaService.cs b/InertiaCoreTests/UnitTestInertiaService.cs
new file mode 100644
index 0000000..d7fb888
--- /dev/null
+++ b/InertiaCoreTests/UnitTestInertiaService.cs
@@ -0,0 +1,128 @@
+using InertiaCore.Contracts;
+using InertiaCore.Extensions;
+using InertiaCore.Services;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+
+
+namespace InertiaCoreTests;
+
+public partial class Tests
+{
+ [Test]
+ [Description("Resolving IInertia from DI returns an InertiaService instance.")]
+ public void TestIInertiaResolvesFromDI()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddInertia();
+
+ var serviceProvider = builder.Services.BuildServiceProvider();
+ var inertia = serviceProvider.GetRequiredService();
+
+ Assert.That(inertia, Is.InstanceOf());
+ }
+
+ [Test]
+ [Description("Resolving IInertia twice from the same scope returns the same instance.")]
+ public void TestIInertiaIsScopedSameInstance()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddInertia();
+
+ var serviceProvider = builder.Services.BuildServiceProvider();
+
+ using var scope1 = serviceProvider.CreateScope();
+ var inertia1 = scope1.ServiceProvider.GetRequiredService();
+ var inertia2 = scope1.ServiceProvider.GetRequiredService();
+
+ Assert.That(inertia1, Is.SameAs(inertia2));
+ }
+
+ [Test]
+ [Description("Resolving IInertia from different scopes returns different instances.")]
+ public void TestIInertiaScopedIsolation()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddInertia();
+
+ var serviceProvider = builder.Services.BuildServiceProvider();
+
+ IInertia inertia1, inertia2;
+ using (var scope1 = serviceProvider.CreateScope())
+ {
+ inertia1 = scope1.ServiceProvider.GetRequiredService();
+ }
+
+ using (var scope2 = serviceProvider.CreateScope())
+ {
+ inertia2 = scope2.ServiceProvider.GetRequiredService();
+ }
+
+ Assert.That(inertia1, Is.Not.SameAs(inertia2));
+ }
+
+ [Test]
+ [Description("InertiaState is registered as Scoped and isolated per scope.")]
+ public void TestInertiaStateScopeIsolation()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddInertia();
+
+ var serviceProvider = builder.Services.BuildServiceProvider();
+
+ InertiaState state1, state2;
+ using (var scope1 = serviceProvider.CreateScope())
+ {
+ state1 = scope1.ServiceProvider.GetRequiredService();
+ state1.SharedProps["key"] = "value from scope 1";
+ }
+
+ using (var scope2 = serviceProvider.CreateScope())
+ {
+ state2 = scope2.ServiceProvider.GetRequiredService();
+ state2.SharedProps["key"] = "value from scope 2";
+ }
+
+ Assert.That(state1.SharedProps["key"], Is.EqualTo("value from scope 1"));
+ Assert.That(state2.SharedProps["key"], Is.Not.EqualTo(state1.SharedProps["key"]));
+ }
+
+ [Test]
+ [Description("Calling Share() on IInertia does not bleed into another scope.")]
+ public void TestSharedDataDoesNotBleedAcrossScopes()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddInertia();
+
+ var serviceProvider = builder.Services.BuildServiceProvider();
+
+ using (var scope1 = serviceProvider.CreateScope())
+ {
+ var inertia = scope1.ServiceProvider.GetRequiredService();
+ inertia.Share("secret", "from scope 1");
+ }
+
+ using (var scope2 = serviceProvider.CreateScope())
+ {
+ var state = scope2.ServiceProvider.GetRequiredService();
+ Assert.That(state.SharedProps.ContainsKey("secret"), Is.False);
+ }
+ }
+
+ [Test]
+ [Description("IInertia.Render returns an IActionResult.")]
+ public async Task TestInertiaServiceRenderReturnsIActionResult()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Services.AddInertia();
+
+ var serviceProvider = builder.Services.BuildServiceProvider();
+ using var scope = serviceProvider.CreateScope();
+ var inertia = scope.ServiceProvider.GetRequiredService();
+
+ var result = await inertia.Render("Test/Page", new { Test = "Data" });
+
+ Assert.That(result, Is.InstanceOf());
+ }
+}
diff --git a/InertiaCoreTests/UnitTestMinimalApi.cs b/InertiaCoreTests/UnitTestMinimalApi.cs
new file mode 100644
index 0000000..b406cb0
--- /dev/null
+++ b/InertiaCoreTests/UnitTestMinimalApi.cs
@@ -0,0 +1,199 @@
+using System.Text.Json;
+using InertiaCore.Models;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Routing;
+
+namespace InertiaCoreTests;
+
+public partial class Tests
+{
+ private static ActionContext PrepareMinimalApiContext(HeaderDictionary? headers = null, string path = "/")
+ {
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Path = path;
+
+ if (headers != null)
+ foreach (var (key, value) in headers)
+ httpContext.Request.Headers[key] = value;
+
+ return new ActionContext(
+ httpContext,
+ httpContext.GetRouteData() ?? new RouteData(),
+ new ActionDescriptor(),
+ new ModelStateDictionary()
+ );
+ }
+
+ [Test]
+ [Description("Minimal API path: Inertia request returns a JsonResult with the correct page model.")]
+ public async Task TestMinimalApiInertiaRequestReturnsJson()
+ {
+ var response = _factory.Render("Test/Page", new { Test = "Test" });
+
+ var context = PrepareMinimalApiContext(new HeaderDictionary
+ {
+ { "X-Inertia", "true" }
+ });
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var result = response.GetResult();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.InstanceOf());
+
+ var page = (result as JsonResult)?.Value as Page;
+ Assert.That(page, Is.Not.Null);
+ Assert.That(page!.Component, Is.EqualTo("Test/Page"));
+ Assert.That(page.Props, Is.EqualTo(new Dictionary
+ {
+ { "test", "Test" },
+ { "errors", new Dictionary(0) }
+ }));
+ });
+ }
+
+ [Test]
+ [Description("Minimal API path: non-Inertia request (full page load) returns a ViewResult.")]
+ public async Task TestMinimalApiNonInertiaRequestReturnsView()
+ {
+ var response = _factory.Render("Test/Page", new { Test = "Test" });
+
+
+ var context = PrepareMinimalApiContext();
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var result = response.GetResult();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.InstanceOf());
+ Assert.That((result as ViewResult)?.ViewName, Is.EqualTo("~/Views/App.cshtml"));
+
+ var page = (result as ViewResult)?.Model as Page;
+ Assert.That(page, Is.Not.Null);
+ Assert.That(page!.Component, Is.EqualTo("Test/Page"));
+ });
+ }
+
+ [Test]
+ [Description("Minimal API path: shared props are merged into the page model.")]
+ public async Task TestMinimalApiSharedPropsAreResolved()
+ {
+ _factory.Share("sharedKey", "sharedValue");
+
+ var response = _factory.Render("Test/Page", new { Test = "Test" });
+
+ var context = PrepareMinimalApiContext(new HeaderDictionary
+ {
+ { "X-Inertia", "true" }
+ });
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = (response.GetResult() as JsonResult)?.Value as Page;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(page?.Props, Contains.Key("sharedKey"));
+ Assert.That(page?.Props["sharedKey"], Is.EqualTo("sharedValue"));
+ });
+ }
+
+ [Test]
+ [Description("Minimal API path: component props take precedence over shared props with the same key.")]
+ public async Task TestMinimalApiComponentPropsOverrideSharedProps()
+ {
+ _factory.Share("test", "shared-value");
+
+ var response = _factory.Render("Test/Page", new { Test = "component-value" });
+
+ var context = PrepareMinimalApiContext(new HeaderDictionary
+ {
+ { "X-Inertia", "true" }
+ });
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = (response.GetResult() as JsonResult)?.Value as Page;
+
+ Assert.That(page?.Props["test"], Is.EqualTo("component-value"));
+ }
+
+ [Test]
+ [Description("Minimal API path: Request.Path is used as the page URL.")]
+ public async Task TestMinimalApiUrlIsPopulatedFromRequestPath()
+ {
+ var response = _factory.Render("Test/Page");
+
+ var context = PrepareMinimalApiContext(
+ headers: new HeaderDictionary { { "X-Inertia", "true" } },
+ path: "/dashboard"
+ );
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+ var page = (response.GetResult() as JsonResult)?.Value as Page;
+
+ Assert.That(page?.Url, Is.EqualTo("/dashboard"));
+ }
+
+ [Test]
+ [Description("Minimal API path: X-Inertia response header is set on Inertia requests.")]
+ public async Task TestMinimalApiInertiaHeaderIsSet()
+ {
+ var response = _factory.Render("Test/Page");
+
+ var context = PrepareMinimalApiContext(new HeaderDictionary
+ {
+ { "X-Inertia", "true" }
+ });
+
+ response.SetContext(context);
+ await response.ProcessResponse();
+
+
+ response.GetJson();
+
+ Assert.That(
+ context.HttpContext.Response.Headers["X-Inertia"].ToString(),
+ Is.EqualTo("true")
+ );
+ }
+
+ [Test]
+ [Description("Minimal API path: IResult.ExecuteAsync writes JSON to the response body for Inertia requests.")]
+ public async Task TestMinimalApiExecuteAsyncWritesJsonBody()
+ {
+ var response = _factory.Render("Test/Page", new { Test = "Test" });
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Headers["X-Inertia"] = "true";
+ httpContext.Response.Body = new MemoryStream();
+
+ await ((IResult)response).ExecuteAsync(httpContext);
+
+ httpContext.Response.Body.Seek(0, SeekOrigin.Begin);
+ var body = await new StreamReader(httpContext.Response.Body).ReadToEndAsync();
+ var page = JsonSerializer.Deserialize(body, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(httpContext.Response.ContentType, Does.Contain("application/json"));
+ Assert.That(page?.Component, Is.EqualTo("Test/Page"));
+ });
+ }
+}
\ No newline at end of file
diff --git a/InertiaCoreTests/UnitTestModelState.cs b/InertiaCoreTests/UnitTestModelState.cs
index a90d646..c697ca2 100644
--- a/InertiaCoreTests/UnitTestModelState.cs
+++ b/InertiaCoreTests/UnitTestModelState.cs
@@ -13,7 +13,7 @@ public async Task TestModelState()
Test = "Test"
});
- var context = PrepareContext(null, null, new Dictionary
+ var context = PrepareContext(null, new Dictionary
{
{ "Field", "Error" }
});
diff --git a/InertiaCoreTests/UnitTestSharedData.cs b/InertiaCoreTests/UnitTestSharedData.cs
index fc64336..8fce3dc 100644
--- a/InertiaCoreTests/UnitTestSharedData.cs
+++ b/InertiaCoreTests/UnitTestSharedData.cs
@@ -1,5 +1,4 @@
using InertiaCore.Models;
-using InertiaCore.Utils;
namespace InertiaCoreTests;
@@ -14,10 +13,9 @@ public async Task TestSharedProps()
Test = "Test"
});
- var sharedProps = new InertiaSharedProps();
- sharedProps.Set("TestShared", "Shared");
+ _factory.Share("TestShared", "Shared");
- var context = PrepareContext(null, sharedProps);
+ var context = PrepareContext();
response.SetContext(context);
await response.ProcessResponse();
@@ -28,7 +26,7 @@ public async Task TestSharedProps()
{
{ "test", "Test" },
{ "testShared", "Shared" },
- { "errors", new Dictionary(0) }
+ { "errors", new Dictionary() }
}));
}
}
diff --git a/InertiaCoreTests/UnitTestTaskExtensions.cs b/InertiaCoreTests/UnitTestTaskExtensions.cs
new file mode 100644
index 0000000..10d9482
--- /dev/null
+++ b/InertiaCoreTests/UnitTestTaskExtensions.cs
@@ -0,0 +1,63 @@
+using InertiaCore.Utils;
+
+namespace InertiaCoreTests;
+
+public partial class Tests
+{
+ [Test]
+ [Description("Task unwraps to correct string value.")]
+ public async Task TestTaskOfStringUnwrapsToString()
+ {
+ Task task = Task.FromResult("hello");
+ var result = await task.UnwrapResultAsync();
+
+ Assert.That(result, Is.EqualTo("hello"));
+ }
+
+ [Test]
+ [Description("Task unwraps to correct int value (boxed).")]
+ public async Task TestTaskOfIntUnwrapsToInt()
+ {
+ Task task = Task.FromResult(42);
+ var result = await task.UnwrapResultAsync();
+
+ Assert.That(result, Is.EqualTo(42));
+ Assert.That(result, Is.TypeOf());
+ }
+
+ [Test]
+ [Description("Non-generic Task unwraps to null without throwing.")]
+ public async Task TestNonGenericTaskUnwrapsToNull()
+ {
+ Task task = Task.CompletedTask;
+ var result = await task.UnwrapResultAsync();
+
+ Assert.That(result, Is.Null);
+ }
+
+ [Test]
+ [Description("Completed task unwraps without blocking.")]
+ public async Task TestCompletedTaskDoesNotBlock()
+ {
+ Task task = Task.FromResult("done");
+ var result = await task.UnwrapResultAsync();
+
+ Assert.That(result, Is.EqualTo("done"));
+ }
+
+ [Test]
+ [Description("Task from async lambda unwraps correctly.")]
+ public async Task TestAsyncLambdaTaskUnwraps()
+ {
+ Task task = AsyncReturnString();
+ var result = await task.UnwrapResultAsync();
+
+ Assert.That(result, Is.EqualTo("async value"));
+ }
+
+ private static async Task AsyncReturnString()
+ {
+ await Task.Yield();
+ return "async value";
+ }
+}
diff --git a/InertiaCoreTests/UnitTestVersionConflict.cs b/InertiaCoreTests/UnitTestVersionConflict.cs
new file mode 100644
index 0000000..eaaf6f8
--- /dev/null
+++ b/InertiaCoreTests/UnitTestVersionConflict.cs
@@ -0,0 +1,207 @@
+using System.Net;
+using InertiaCore;
+using InertiaCore.Extensions;
+using InertiaCore.Models;
+using InertiaCore.Utils;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+
+namespace InertiaCoreTests;
+
+public partial class Tests
+{
+ [Test]
+ [Description("GET with version mismatch returns 409 Conflict with X-Inertia-Location header.")]
+ public async Task TestVersionConflictOnGet()
+ {
+ _factory.Version("server-version");
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Version", "old-version" }
+ };
+
+ var context = PrepareContext(headers);
+ context.HttpContext.Request.Method = "GET";
+
+ var calledNext = false;
+ await RunVersionCheck(context, () => calledNext = true, _factory.GetVersion);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict));
+ Assert.That(context.HttpContext.Response.Headers["X-Inertia-Location"].ToString(), Is.Not.Empty);
+ Assert.That(calledNext, Is.False);
+ });
+ }
+
+ [Test]
+ [Description("POST with version mismatch returns 409 Conflict (protocol requirement).")]
+ public async Task TestVersionConflictOnPost()
+ {
+ _factory.Version("server-version");
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Version", "old-version" }
+ };
+
+ var context = PrepareContext(headers);
+ context.HttpContext.Request.Method = "POST";
+
+ await RunVersionCheck(context, () => { }, _factory.GetVersion);
+
+ Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict));
+ }
+
+ [Test]
+ [Description("PUT with version mismatch returns 409 Conflict.")]
+ public async Task TestVersionConflictOnPut()
+ {
+ _factory.Version("server-version");
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Version", "old-version" }
+ };
+
+ var context = PrepareContext(headers);
+ context.HttpContext.Request.Method = "PUT";
+
+ await RunVersionCheck(context, () => { }, _factory.GetVersion);
+
+ Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict));
+ }
+
+ [Test]
+ [Description("PATCH with version mismatch returns 409 Conflict.")]
+ public async Task TestVersionConflictOnPatch()
+ {
+ _factory.Version("server-version");
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Version", "old-version" }
+ };
+
+ var context = PrepareContext(headers);
+ context.HttpContext.Request.Method = "PATCH";
+
+ await RunVersionCheck(context, () => { }, _factory.GetVersion);
+
+ Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict));
+ }
+
+ [Test]
+ [Description("DELETE with version mismatch returns 409 Conflict.")]
+ public async Task TestVersionConflictOnDelete()
+ {
+ _factory.Version("server-version");
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Version", "old-version" }
+ };
+
+ var context = PrepareContext(headers);
+ context.HttpContext.Request.Method = "DELETE";
+
+ await RunVersionCheck(context, () => { }, _factory.GetVersion);
+
+ Assert.That(context.HttpContext.Response.StatusCode, Is.EqualTo((int)HttpStatusCode.Conflict));
+ }
+
+ [Test]
+ [Description("Request with matching version does NOT return 409.")]
+ public async Task TestNoConflictOnMatchingVersion()
+ {
+ _factory.Version("test-version");
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Version", "test-version" }
+ };
+
+ var context = PrepareContext(headers);
+ context.HttpContext.Request.Method = "GET";
+
+ var calledNext = false;
+ await RunVersionCheck(context, () => calledNext = true, _factory.GetVersion);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(context.HttpContext.Response.StatusCode, Is.Not.EqualTo((int)HttpStatusCode.Conflict));
+ Assert.That(calledNext, Is.True);
+ });
+ }
+
+ [Test]
+ [Description("Non-Inertia request with version mismatch does NOT return 409.")]
+ public async Task TestNoConflictOnNonInertiaRequest()
+ {
+ _factory.Version("server-version");
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia-Version", "old-version" }
+ };
+
+ var context = PrepareContext(headers);
+ context.HttpContext.Request.Method = "GET";
+
+ var calledNext = false;
+ await RunVersionCheck(context, () => calledNext = true, _factory.GetVersion);
+
+ Assert.That(calledNext, Is.True);
+ }
+
+ [Test]
+ [Description("When no version is configured, the version check is skipped.")]
+ public async Task TestNoConflictWhenNoVersionConfigured()
+ {
+ _factory.Version((string?)null);
+
+ var headers = new HeaderDictionary
+ {
+ { "X-Inertia", "true" },
+ { "X-Inertia-Version", "client-version" }
+ };
+
+ var context = PrepareContext(headers);
+ context.HttpContext.Request.Method = "GET";
+
+ var calledNext = false;
+ await RunVersionCheck(context, () => calledNext = true, _factory.GetVersion);
+
+ Assert.That(calledNext, Is.True);
+ }
+
+ ///
+ /// Simulates the Inertia version-check middleware logic directly.
+ ///
+ private static async Task RunVersionCheck(ActionContext actionContext, Action next, Func getVersion)
+ {
+ var context = actionContext.HttpContext;
+
+ var serverVersion = getVersion();
+ if (serverVersion != null
+ && context.IsInertiaRequest()
+ && context.Request.Headers[InertiaHeader.Version] != serverVersion)
+ {
+ context.Response.Headers.Override(InertiaHeader.Location, context.RequestedUri());
+ context.Response.StatusCode = (int)HttpStatusCode.Conflict;
+ await context.Response.CompleteAsync();
+ return;
+ }
+
+ next();
+ await Task.CompletedTask;
+ }
+}
diff --git a/InertiaCoreTests/UnitTestVite.cs b/InertiaCoreTests/UnitTestVite.cs
index 9dde8df..02e910b 100644
--- a/InertiaCoreTests/UnitTestVite.cs
+++ b/InertiaCoreTests/UnitTestVite.cs
@@ -33,7 +33,7 @@ public void TestViteConfiguration()
[Test]
[Description("Test if the Vite Helper handles hot module reloading properly.")]
- public void TestHot()
+ public async Task TestHot()
{
var fileSystem = new MockFileSystem(new Dictionary
{
@@ -45,7 +45,7 @@ public void TestHot()
var mock = new Mock(options.Object);
mock.Object.UseFileSystem(fileSystem);
- var htmlResult = mock.Object.ReactRefresh();
+ var htmlResult = await mock.Object.ReactRefreshAsync();
const string inner =
"\n\t"));
// Basic JS File with CSS import
fileSystem.AddFile(@"/wwwroot/build/manifest.json",
new MockFileData("{\"app.tsx\": {\"file\": \"assets/main.js\",\"css\": [\"assets/index.css\"]}}"));
+ mock.Object.ClearManifestCache();
- result = mock.Object.Input("app.tsx");
+ result = await mock.Object.InputAsync("app.tsx");
Assert.That(result.ToString(),
Is.EqualTo(
"\n\t \n\t"));
@@ -116,18 +118,20 @@ public void TestViteInput()
// Basic CSS file
fileSystem.AddFile(@"/wwwroot/build/manifest.json",
new MockFileData("{\"index.scss\": {\"file\": \"assets/index.css\"}}"));
+ mock.Object.ClearManifestCache();
- result = mock.Object.Input("index.scss");
+ result = await mock.Object.InputAsync("index.scss");
Assert.That(result.ToString(), Is.EqualTo(" \n\t"));
// Basic CSS file with custom builder dir
fileSystem.AddFile(@"/wwwroot/manifest.json",
new MockFileData("{\"index.scss\": {\"file\": \"assets/index.css\"}}"));
+ mock.Object.ClearManifestCache();
options.SetupGet(x => x.Value).Returns(new ViteOptions
{
BuildDirectory = null
});
- result = mock.Object.Input("index.scss");
+ result = await mock.Object.InputAsync("index.scss");
Assert.That(result.ToString(), Is.EqualTo(" \n\t"));
// Hot file with css import
@@ -137,7 +141,7 @@ public void TestViteInput()
});
fileSystem.AddFile(@"/wwwroot/hot", new MockFileData("http://127.0.0.1:5174"));
- result = mock.Object.Input("index.scss");
+ result = await mock.Object.InputAsync("index.scss");
Assert.That(result.ToString(), Is.EqualTo(
"\n\t" +
"\n\t"));
@@ -148,7 +152,7 @@ public void TestViteInput()
{
BuildDirectory = null
});
- result = mock.Object.Input("index.scss");
+ result = await mock.Object.InputAsync("index.scss");
Assert.That(result.ToString(), Is.EqualTo(
"\n\t" +
"\n\t"));
@@ -158,13 +162,13 @@ public void TestViteInput()
{
BuildDirectory = ""
});
- result = mock.Object.Input("index.scss");
+ result = await mock.Object.InputAsync("index.scss");
Assert.That(result.ToString(), Is.EqualTo(
"\n\t" +
"\n\t"));
// Basic JS File via hot
- result = mock.Object.Input("app.tsx");
+ result = await mock.Object.InputAsync("app.tsx");
Assert.That(result.ToString(), Is.EqualTo(
"\n\t" +
"\n\t"));
diff --git a/ProjectsLocal/AspNetCore.InertiaCore.0.0.9.nupkg b/ProjectsLocal/AspNetCore.InertiaCore.0.0.9.nupkg
new file mode 100644
index 0000000..dfc9f35
Binary files /dev/null and b/ProjectsLocal/AspNetCore.InertiaCore.0.0.9.nupkg differ
diff --git a/dotnet-tools.json b/dotnet-tools.json
new file mode 100644
index 0000000..b0e38ab
--- /dev/null
+++ b/dotnet-tools.json
@@ -0,0 +1,5 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {}
+}
\ No newline at end of file