From 1dc8b6b637888582177a41296290558f4f5ff7ed Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 14 Dec 2024 15:24:15 -0500 Subject: [PATCH 01/34] added header constants --- InertiaCore/Extensions/Configure.cs | 4 ++-- InertiaCore/Extensions/InertiaExtensions.cs | 7 ++++--- InertiaCore/Response.cs | 2 +- InertiaCore/Utils/Header.cs | 18 ++++++++++++++++++ InertiaCore/Utils/LocationResult.cs | 2 +- 5 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 InertiaCore/Utils/Header.cs diff --git a/InertiaCore/Extensions/Configure.cs b/InertiaCore/Extensions/Configure.cs index 6be799c..d0d12b5 100644 --- a/InertiaCore/Extensions/Configure.cs +++ b/InertiaCore/Extensions/Configure.cs @@ -25,7 +25,7 @@ public static IApplicationBuilder UseInertia(this IApplicationBuilder app) { if (context.IsInertiaRequest() && context.Request.Method == "GET" - && context.Request.Headers["X-Inertia-Version"] != Inertia.GetVersion()) + && context.Request.Headers[Header.Version] != Inertia.GetVersion()) { await OnVersionChange(context, app); return; @@ -69,7 +69,7 @@ private static async Task OnVersionChange(HttpContext context, IApplicationBuild if (tempData.Any()) tempData.Keep(); - context.Response.Headers.Add("X-Inertia-Location", context.RequestedUri()); + context.Response.Headers.Add(Header.Location, context.RequestedUri()); context.Response.StatusCode = (int)HttpStatusCode.Conflict; await context.Response.CompleteAsync(); diff --git a/InertiaCore/Extensions/InertiaExtensions.cs b/InertiaCore/Extensions/InertiaExtensions.cs index dbcfae3..10844ea 100644 --- a/InertiaCore/Extensions/InertiaExtensions.cs +++ b/InertiaCore/Extensions/InertiaExtensions.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using InertiaCore.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -12,13 +13,13 @@ internal static IEnumerable Only(this object obj, IEnumerable on .Intersect(only, StringComparer.OrdinalIgnoreCase).ToList(); internal static List GetPartialData(this ActionContext context) => - context.HttpContext.Request.Headers["X-Inertia-Partial-Data"] + context.HttpContext.Request.Headers[Header.PartialOnly] .FirstOrDefault()?.Split(",") .Where(s => !string.IsNullOrEmpty(s)) .ToList() ?? new List(); internal static bool IsInertiaPartialComponent(this ActionContext context, string component) => - context.HttpContext.Request.Headers["X-Inertia-Partial-Component"] == component; + context.HttpContext.Request.Headers[Header.PartialComponent] == component; internal static string RequestedUri(this HttpContext context) => Uri.UnescapeDataString(context.Request.GetEncodedPathAndQuery()); @@ -26,7 +27,7 @@ internal static string RequestedUri(this HttpContext context) => internal static string RequestedUri(this ActionContext context) => context.HttpContext.RequestedUri(); internal static bool IsInertiaRequest(this HttpContext context) => - bool.TryParse(context.Request.Headers["X-Inertia"], out _); + bool.TryParse(context.Request.Headers[Header.Inertia], out _); internal static bool IsInertiaRequest(this ActionContext context) => context.HttpContext.IsInertiaRequest(); diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 865ff45..f9bd802 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -81,7 +81,7 @@ protected internal void ProcessResponse() protected internal JsonResult GetJson() { - _context!.HttpContext.Response.Headers.Add("X-Inertia", "true"); + _context!.HttpContext.Response.Headers.Add(Header.Inertia, "true"); _context!.HttpContext.Response.Headers.Add("Vary", "Accept"); _context!.HttpContext.Response.StatusCode = 200; diff --git a/InertiaCore/Utils/Header.cs b/InertiaCore/Utils/Header.cs new file mode 100644 index 0000000..b729dbf --- /dev/null +++ b/InertiaCore/Utils/Header.cs @@ -0,0 +1,18 @@ +namespace InertiaCore.Utils; + +public static class Header +{ + public const string Inertia = "X-Inertia"; + + public const string ErrorBag = "X-Inertia-Error-Bag"; + + public const string Location = "X-Inertia-Location"; + + public const string Version = "X-Inertia-Version"; + + public const string PartialComponent = "X-Inertia-Partial-Component"; + + public const string PartialOnly = "X-Inertia-Partial-Data"; + + public const string PartialExcept = "X-Inertia-Partial-Except"; +} diff --git a/InertiaCore/Utils/LocationResult.cs b/InertiaCore/Utils/LocationResult.cs index 6e358ed..3ddcedd 100644 --- a/InertiaCore/Utils/LocationResult.cs +++ b/InertiaCore/Utils/LocationResult.cs @@ -14,7 +14,7 @@ public async Task ExecuteResultAsync(ActionContext context) { if (context.IsInertiaRequest()) { - context.HttpContext.Response.Headers.Add("X-Inertia-Location", _url); + context.HttpContext.Response.Headers.Add(Header.Location, _url); await new StatusCodeResult((int)HttpStatusCode.Conflict).ExecuteResultAsync(context); return; } From 3a334498c0dc4ffc96860bc2df7ce3aa0c435cb2 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 14 Dec 2024 17:07:47 -0500 Subject: [PATCH 02/34] refactor resolve props --- InertiaCore/Extensions/InertiaExtensions.cs | 18 +++++-- InertiaCore/Response.cs | 59 +++++++++++++-------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/InertiaCore/Extensions/InertiaExtensions.cs b/InertiaCore/Extensions/InertiaExtensions.cs index 10844ea..ba6cc0c 100644 --- a/InertiaCore/Extensions/InertiaExtensions.cs +++ b/InertiaCore/Extensions/InertiaExtensions.cs @@ -8,9 +8,21 @@ namespace InertiaCore.Extensions; internal static class InertiaExtensions { - internal static IEnumerable Only(this object obj, IEnumerable only) => - obj.GetType().GetProperties().Select(c => c.Name) - .Intersect(only, StringComparer.OrdinalIgnoreCase).ToList(); + internal static Dictionary OnlyProps(this ActionContext context, Dictionary props) + { + var onlyKeys = context.HttpContext.Request.Headers[Header.PartialOnly].ToString().Split(',').Select(k => k.Trim()).ToList(); + + return props.Where(kv => onlyKeys.Contains(kv.Key, StringComparer.OrdinalIgnoreCase)) + .ToDictionary(kv => kv.Key, kv => kv.Value); + } + + internal static Dictionary ExceptProps(this ActionContext context, Dictionary props) + { + var exceptKeys = context.HttpContext.Request.Headers[Header.PartialExcept].ToString().Split(',').Select(k => k.Trim()).ToList(); + + return props.Where(kv => exceptKeys.Contains(kv.Key, StringComparer.OrdinalIgnoreCase) == false) + .ToDictionary(kv => kv.Key, kv => kv.Value); + } internal static List GetPartialData(this ActionContext context) => context.HttpContext.Request.Headers[Header.PartialOnly] diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index f9bd802..13348c1 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -37,29 +37,10 @@ protected internal void ProcessResponse() { Component = _component, Version = _version, - Url = _context!.RequestedUri() + Url = _context!.RequestedUri(), + Props = ResolveProperties(_props.GetType().GetProperties().ToDictionary(o => o.Name.ToCamelCase(), o => o.GetValue(_props))) }; - var partial = _context!.GetPartialData(); - if (partial.Any() && _context!.IsInertiaPartialComponent(_component)) - { - var only = _props.Only(partial); - var partialProps = only.ToDictionary(o => o.ToCamelCase(), o => - _props.GetType().GetProperty(o)?.GetValue(_props)); - - page.Props = partialProps; - } - else - { - var props = _props.GetType().GetProperties() - .Where(o => o.PropertyType != typeof(LazyProp)) - .ToDictionary(o => o.Name.ToCamelCase(), o => o.GetValue(_props)); - - page.Props = props; - } - - page.Props = PrepareProps(page.Props); - var shared = _context!.HttpContext.Features.Get(); if (shared != null) page.Props = shared.GetMerged(page.Props); @@ -127,4 +108,40 @@ public Response WithViewData(IDictionary viewData) _viewData = viewData; return this; } + + private Dictionary ResolveProperties(Dictionary props) + { + bool isPartial = _context!.IsInertiaPartialComponent(_component); + + if (!isPartial) + { + props = props + .Where(kv => kv.Value is not LazyProp) + .ToDictionary(kv => kv.Key, kv => kv.Value); + } + + if (isPartial && _context!.HttpContext.Request.Headers.ContainsKey(Header.PartialOnly)) + { + props = ResolveOnly(props); + } + + if (isPartial && _context!.HttpContext.Request.Headers.ContainsKey(Header.PartialExcept)) + { + props = ResolveExcept(props); + } + + props = PrepareProps(props); + + return props; + } + + private Dictionary ResolveOnly(Dictionary props) + { + return _context!.OnlyProps(props); + } + + private Dictionary ResolveExcept(Dictionary props) + { + return _context!.ExceptProps(props); + } } From c456148b3e0f3b7603ac12dc480f8e0be33d956e Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 14 Dec 2024 17:08:57 -0500 Subject: [PATCH 03/34] added always prop --- InertiaCore/Inertia.cs | 3 +++ InertiaCore/Response.cs | 14 ++++++++++++++ InertiaCore/ResponseFactory.cs | 6 ++++++ InertiaCore/Utils/AlwaysProp.cs | 25 +++++++++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 InertiaCore/Utils/AlwaysProp.cs diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index 729e52f..dec6eda 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -28,5 +28,8 @@ public static class Inertia public static void Share(IDictionary data) => _factory.Share(data); + public static AlwaysProp Always(object? value) => _factory.Always(value); + + public static AlwaysProp Always(Func callback) => _factory.Always(callback); public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); } diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 13348c1..acab917 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -56,6 +56,7 @@ protected internal void ProcessResponse() { Func f => f.Invoke(), LazyProp l => l.Invoke(), + AlwaysProp l => l.Invoke(), _ => pair.Value }); } @@ -130,6 +131,8 @@ public Response WithViewData(IDictionary viewData) props = ResolveExcept(props); } + props = ResolveAlways(props); + props = PrepareProps(props); return props; @@ -144,4 +147,15 @@ public Response WithViewData(IDictionary viewData) { return _context!.ExceptProps(props); } + + private Dictionary ResolveAlways(Dictionary props) + { + var alwaysProps = _props.GetType().GetProperties() + .Where(o => o.PropertyType == typeof(AlwaysProp)) + .ToDictionary(o => o.Name.ToCamelCase(), o => o.GetValue(_props)); ; + + return props + .Where(kv => kv.Value is not AlwaysProp) + .Concat(alwaysProps).ToDictionary(kv => kv.Key, kv => kv.Value); + } } diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 0692b40..288721e 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -21,6 +21,9 @@ internal interface IResponseFactory public LocationResult Location(string url); public void Share(string key, object? value); public void Share(IDictionary data); + 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); } @@ -121,4 +124,7 @@ public void Share(IDictionary data) public LazyProp Lazy(Func callback) => new LazyProp(callback); public LazyProp Lazy(Func> callback) => new LazyProp(callback); + public AlwaysProp Always(object? value) => new AlwaysProp(value); + public AlwaysProp Always(Func callback) => new AlwaysProp(callback); + public AlwaysProp Always(Func> callback) => new AlwaysProp(callback); } diff --git a/InertiaCore/Utils/AlwaysProp.cs b/InertiaCore/Utils/AlwaysProp.cs new file mode 100644 index 0000000..12182de --- /dev/null +++ b/InertiaCore/Utils/AlwaysProp.cs @@ -0,0 +1,25 @@ +namespace InertiaCore.Utils; + +public class AlwaysProp +{ + private readonly object? _value; + + public AlwaysProp(object? value) + { + _value = value; + } + + public object? Invoke() + { + // Check if the value is a callable delegate + return Task.Run(() => + { + if (_value is Delegate callable) + { + return callable.DynamicInvoke(); + } + + return _value; + }).GetAwaiter().GetResult(); + } +} From 7d0d3bfa8d8414435de76a67e7e1487507030ff9 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 14 Dec 2024 17:15:46 -0500 Subject: [PATCH 04/34] fix some compile time warnings --- InertiaCore/Extensions/Configure.cs | 2 +- InertiaCore/Extensions/InertiaExtensions.cs | 12 ++++++++++++ InertiaCore/Response.cs | 4 ++-- InertiaCore/Utils/InertiaActionFilter.cs | 2 +- InertiaCore/Utils/LocationResult.cs | 2 +- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/InertiaCore/Extensions/Configure.cs b/InertiaCore/Extensions/Configure.cs index 6be799c..2e9ce05 100644 --- a/InertiaCore/Extensions/Configure.cs +++ b/InertiaCore/Extensions/Configure.cs @@ -69,7 +69,7 @@ private static async Task OnVersionChange(HttpContext context, IApplicationBuild if (tempData.Any()) tempData.Keep(); - context.Response.Headers.Add("X-Inertia-Location", context.RequestedUri()); + context.Response.Headers.Override(Header.Location, context.RequestedUri()); context.Response.StatusCode = (int)HttpStatusCode.Conflict; await context.Response.CompleteAsync(); diff --git a/InertiaCore/Extensions/InertiaExtensions.cs b/InertiaCore/Extensions/InertiaExtensions.cs index dbcfae3..e92dbd9 100644 --- a/InertiaCore/Extensions/InertiaExtensions.cs +++ b/InertiaCore/Extensions/InertiaExtensions.cs @@ -31,4 +31,16 @@ internal static bool IsInertiaRequest(this HttpContext context) => internal static bool IsInertiaRequest(this ActionContext context) => context.HttpContext.IsInertiaRequest(); internal static string ToCamelCase(this string s) => JsonNamingPolicy.CamelCase.ConvertName(s); + + internal static bool Override(this IDictionary dictionary, TKey key, TValue value) + { + if (dictionary.ContainsKey(key)) + { + dictionary[key] = value; + return true; + } + + dictionary.Add(key, value); + return false; + } } diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 865ff45..966a95f 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -81,8 +81,8 @@ protected internal void ProcessResponse() protected internal JsonResult GetJson() { - _context!.HttpContext.Response.Headers.Add("X-Inertia", "true"); - _context!.HttpContext.Response.Headers.Add("Vary", "Accept"); + _context!.HttpContext.Response.Headers.Override(Header.Inertia, "true"); + _context!.HttpContext.Response.Headers.Override("Vary", "Accept"); _context!.HttpContext.Response.StatusCode = 200; return new JsonResult(_page, new JsonSerializerOptions diff --git a/InertiaCore/Utils/InertiaActionFilter.cs b/InertiaCore/Utils/InertiaActionFilter.cs index f3c5b75..c2ff160 100644 --- a/InertiaCore/Utils/InertiaActionFilter.cs +++ b/InertiaCore/Utils/InertiaActionFilter.cs @@ -32,7 +32,7 @@ public void OnActionExecuted(ActionExecutedContext context) }; if (destinationUrl == null) return; - context.HttpContext.Response.Headers.Add("Location", destinationUrl); + context.HttpContext.Response.Headers.Override("Location", destinationUrl); context.Result = new StatusCodeResult((int)HttpStatusCode.RedirectMethod); } diff --git a/InertiaCore/Utils/LocationResult.cs b/InertiaCore/Utils/LocationResult.cs index 6e358ed..436f372 100644 --- a/InertiaCore/Utils/LocationResult.cs +++ b/InertiaCore/Utils/LocationResult.cs @@ -14,7 +14,7 @@ public async Task ExecuteResultAsync(ActionContext context) { if (context.IsInertiaRequest()) { - context.HttpContext.Response.Headers.Add("X-Inertia-Location", _url); + context.HttpContext.Response.Headers.Override(Header.Location, _url); await new StatusCodeResult((int)HttpStatusCode.Conflict).ExecuteResultAsync(context); return; } From f9ad84b836c0fd459a2e88fbd1fa4656a79693fa Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 14 Dec 2024 22:26:54 -0500 Subject: [PATCH 05/34] added always prop test --- InertiaCore/Utils/AlwaysProp.cs | 11 +- InertiaCoreTests/UnitTestAlwaysData.cs | 175 +++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 InertiaCoreTests/UnitTestAlwaysData.cs diff --git a/InertiaCore/Utils/AlwaysProp.cs b/InertiaCore/Utils/AlwaysProp.cs index 12182de..1ccc9b8 100644 --- a/InertiaCore/Utils/AlwaysProp.cs +++ b/InertiaCore/Utils/AlwaysProp.cs @@ -12,11 +12,16 @@ public AlwaysProp(object? value) public object? Invoke() { // Check if the value is a callable delegate - return Task.Run(() => + return Task.Run(async () => { - if (_value is Delegate callable) + if (_value is Func> asyncCallable) { - return callable.DynamicInvoke(); + return await asyncCallable.Invoke(); + } + + if (_value is Func callable) + { + return callable.Invoke(); } return _value; diff --git a/InertiaCoreTests/UnitTestAlwaysData.cs b/InertiaCoreTests/UnitTestAlwaysData.cs new file mode 100644 index 0000000..f3d7bcb --- /dev/null +++ b/InertiaCoreTests/UnitTestAlwaysData.cs @@ -0,0 +1,175 @@ +using InertiaCore.Models; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the always data is fetched properly.")] + public void TestAlwaysData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestAlways = _factory.Always(() => + { + return "Always"; + }) + }); + + var context = PrepareContext(); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testAlways", "Always" }, + { "errors", new Dictionary(0) } + })); + } + + [Test] + [Description("Test if the always data is fetched properly with specified partial props.")] + public void TestAlwaysPartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestAlways = _factory.Always(() => "Always") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testAlways" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testAlways", "Always" }, + { "errors", new Dictionary(0) } + })); + } + + [Test] + [Description("Test if the always async data is fetched properly.")] + public void TestAlwaysAsyncData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Always Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestAlways = _factory.Always(testFunction) + }); + + var context = PrepareContext(); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testAlways", "Always Async" }, + { "errors", new Dictionary(0) } + })); + } + + [Test] + [Description("Test if the always async data is fetched properly with specified partial props.")] + public void TestAlwaysAsyncPartialData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Always Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestAlways = _factory.Always(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testAlways" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testAlways", "Always Async" }, + { "errors", new Dictionary(0) } + })); + } + + [Test] + [Description("Test if the always async data is fetched properly without specified partial props.")] + public void TestAlwaysAsyncPartialDataOmitted() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Always Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestAlways = _factory.Always(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testAlways", "Always Async" }, + { "errors", new Dictionary(0) } + })); + } +} From 77473d6572d676c97c7df24e9f560df3db4ae78e Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 14 Dec 2024 22:27:15 -0500 Subject: [PATCH 06/34] added async task wrapper --- InertiaCore/Inertia.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index dec6eda..8519b53 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -31,5 +31,10 @@ public static class Inertia public static AlwaysProp Always(object? value) => _factory.Always(value); public static AlwaysProp Always(Func callback) => _factory.Always(callback); + + public static AlwaysProp Always(Func> callback) => _factory.Always(callback); + public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); + + public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback); } From f64ca769b65a239fd574d13ebfbc2ee82d57144e Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 14 Dec 2024 22:30:28 -0500 Subject: [PATCH 07/34] .net 8 --- InertiaCore/InertiaCore.csproj | 2 +- InertiaCoreTests/InertiaCoreTests.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/InertiaCore/InertiaCore.csproj b/InertiaCore/InertiaCore.csproj index b54f108..05d7e54 100644 --- a/InertiaCore/InertiaCore.csproj +++ b/InertiaCore/InertiaCore.csproj @@ -3,7 +3,7 @@ enable enable 0.0.9 - net6.0;net7.0 + net6.0;net7.0;net8.0 AspNetCore.InertiaCore kapi2289 Inertia.js ASP.NET Adapter. https://inertiajs.com/ diff --git a/InertiaCoreTests/InertiaCoreTests.csproj b/InertiaCoreTests/InertiaCoreTests.csproj index f85c6b7..81f3de9 100644 --- a/InertiaCoreTests/InertiaCoreTests.csproj +++ b/InertiaCoreTests/InertiaCoreTests.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0 + net6.0;net7.0;net8.0 enable enable From 51fe030f143c64a7043776101d8a463c7755f953 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 20 Dec 2024 23:56:40 -0500 Subject: [PATCH 08/34] restore header keys --- InertiaCore/Extensions/Configure.cs | 2 +- InertiaCore/Response.cs | 2 +- InertiaCore/Utils/LocationResult.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/InertiaCore/Extensions/Configure.cs b/InertiaCore/Extensions/Configure.cs index 2e9ce05..f0ff601 100644 --- a/InertiaCore/Extensions/Configure.cs +++ b/InertiaCore/Extensions/Configure.cs @@ -69,7 +69,7 @@ private static async Task OnVersionChange(HttpContext context, IApplicationBuild if (tempData.Any()) tempData.Keep(); - context.Response.Headers.Override(Header.Location, context.RequestedUri()); + context.Response.Headers.Override("X-Inertia-Location", context.RequestedUri()); context.Response.StatusCode = (int)HttpStatusCode.Conflict; await context.Response.CompleteAsync(); diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 966a95f..ee364e6 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -81,7 +81,7 @@ protected internal void ProcessResponse() protected internal JsonResult GetJson() { - _context!.HttpContext.Response.Headers.Override(Header.Inertia, "true"); + _context!.HttpContext.Response.Headers.Override("X-Inertia", "true"); _context!.HttpContext.Response.Headers.Override("Vary", "Accept"); _context!.HttpContext.Response.StatusCode = 200; diff --git a/InertiaCore/Utils/LocationResult.cs b/InertiaCore/Utils/LocationResult.cs index 436f372..03887d9 100644 --- a/InertiaCore/Utils/LocationResult.cs +++ b/InertiaCore/Utils/LocationResult.cs @@ -14,7 +14,7 @@ public async Task ExecuteResultAsync(ActionContext context) { if (context.IsInertiaRequest()) { - context.HttpContext.Response.Headers.Override(Header.Location, _url); + context.HttpContext.Response.Headers.Override("X-Inertia-Location", _url); await new StatusCodeResult((int)HttpStatusCode.Conflict).ExecuteResultAsync(context); return; } From 62a0864c2f08ea422b742abc94e0511e3d2fe7d8 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 20 Dec 2024 23:58:09 -0500 Subject: [PATCH 09/34] added .net 9 --- InertiaCore/InertiaCore.csproj | 2 +- InertiaCoreTests/InertiaCoreTests.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/InertiaCore/InertiaCore.csproj b/InertiaCore/InertiaCore.csproj index 05d7e54..5f68508 100644 --- a/InertiaCore/InertiaCore.csproj +++ b/InertiaCore/InertiaCore.csproj @@ -3,7 +3,7 @@ enable enable 0.0.9 - net6.0;net7.0;net8.0 + net6.0;net7.0;net8.0;net9.0 AspNetCore.InertiaCore kapi2289 Inertia.js ASP.NET Adapter. https://inertiajs.com/ diff --git a/InertiaCoreTests/InertiaCoreTests.csproj b/InertiaCoreTests/InertiaCoreTests.csproj index 81f3de9..328dafe 100644 --- a/InertiaCoreTests/InertiaCoreTests.csproj +++ b/InertiaCoreTests/InertiaCoreTests.csproj @@ -1,7 +1,7 @@ - net6.0;net7.0;net8.0 + net6.0;net7.0;net8.0;net9.0 enable enable From 2ab07651da7f48d66bcae34b187f60ec6aca4d6c Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 00:21:26 -0500 Subject: [PATCH 10/34] added ignore first load --- InertiaCore/Response.cs | 2 +- InertiaCore/Utils/IgnoreFirstLoad.cs | 6 ++++++ InertiaCore/Utils/LazyProp.cs | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 InertiaCore/Utils/IgnoreFirstLoad.cs diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 87b53da..263b5b5 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -117,7 +117,7 @@ public Response WithViewData(IDictionary viewData) if (!isPartial) { props = props - .Where(kv => kv.Value is not LazyProp) + .Where(kv => (kv.Value as IgnoreFirstLoad) == null) .ToDictionary(kv => kv.Key, kv => kv.Value); } diff --git a/InertiaCore/Utils/IgnoreFirstLoad.cs b/InertiaCore/Utils/IgnoreFirstLoad.cs new file mode 100644 index 0000000..f5ff0f7 --- /dev/null +++ b/InertiaCore/Utils/IgnoreFirstLoad.cs @@ -0,0 +1,6 @@ +namespace InertiaCore.Utils; + +public interface IgnoreFirstLoad +{ + +} diff --git a/InertiaCore/Utils/LazyProp.cs b/InertiaCore/Utils/LazyProp.cs index 1f74f9f..46004b5 100644 --- a/InertiaCore/Utils/LazyProp.cs +++ b/InertiaCore/Utils/LazyProp.cs @@ -1,6 +1,6 @@ namespace InertiaCore.Utils; -public class LazyProp +public class LazyProp : IgnoreFirstLoad { private readonly Func> _callback; From bf08e03df726867b3e8e06a6b90a62bd7f0e3683 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 00:22:03 -0500 Subject: [PATCH 11/34] add optional prop --- InertiaCore/Response.cs | 1 + InertiaCore/ResponseFactory.cs | 4 ++++ InertiaCore/Utils/OptionalProp.cs | 12 ++++++++++++ 3 files changed, 17 insertions(+) create mode 100644 InertiaCore/Utils/OptionalProp.cs diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 263b5b5..a7344d7 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -56,6 +56,7 @@ protected internal void ProcessResponse() { Func f => f.Invoke(), LazyProp l => l.Invoke(), + OptionalProp l => l.Invoke(), AlwaysProp l => l.Invoke(), _ => pair.Value }); diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 288721e..1ed9277 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -26,6 +26,8 @@ internal interface IResponseFactory public AlwaysProp Always(Func> callback); public LazyProp Lazy(Func callback); public LazyProp Lazy(Func> callback); + public OptionalProp Optional(Func callback); + public OptionalProp Optional(Func> callback); } internal class ResponseFactory : IResponseFactory @@ -127,4 +129,6 @@ public void Share(IDictionary data) public AlwaysProp Always(object? value) => new AlwaysProp(value); public AlwaysProp Always(Func callback) => new AlwaysProp(callback); public AlwaysProp Always(Func> callback) => new AlwaysProp(callback); + public OptionalProp Optional(Func callback) => new OptionalProp(callback); + public OptionalProp Optional(Func> callback) => new OptionalProp(callback); } diff --git a/InertiaCore/Utils/OptionalProp.cs b/InertiaCore/Utils/OptionalProp.cs new file mode 100644 index 0000000..20c6e5a --- /dev/null +++ b/InertiaCore/Utils/OptionalProp.cs @@ -0,0 +1,12 @@ +namespace InertiaCore.Utils; + +public class OptionalProp : IgnoreFirstLoad +{ + private readonly Func> _callback; + + public OptionalProp(Func callback) => _callback = async () => await Task.FromResult(callback()); + + public OptionalProp(Func> callback) => _callback = callback; + + public object? Invoke() => Task.Run(() => _callback.Invoke()).GetAwaiter().GetResult(); +} From 3e30450aebc9c3046dc54362a7d32d764ae962f7 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 00:45:24 -0500 Subject: [PATCH 12/34] added optional test --- InertiaCoreTests/UnitTestOptionalData.cs | 139 +++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 InertiaCoreTests/UnitTestOptionalData.cs diff --git a/InertiaCoreTests/UnitTestOptionalData.cs b/InertiaCoreTests/UnitTestOptionalData.cs new file mode 100644 index 0000000..8d8365f --- /dev/null +++ b/InertiaCoreTests/UnitTestOptionalData.cs @@ -0,0 +1,139 @@ +using InertiaCore.Models; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the optional data is fetched properly.")] + public void TestOptionalData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(() => + { + Assert.Fail(); + return "Optional"; + }) + }); + + var context = PrepareContext(); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + } + + [Test] + [Description("Test if the optional data is fetched properly with specified partial props.")] + public void TestOptionalPartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(() => "Optional") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testOptional" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testOptional", "Optional" }, + { "errors", new Dictionary(0) } + })); + } + + + [Test] + [Description("Test if the optional async data is fetched properly.")] + public void TestOptionalAsyncData() + { + var testFunction = new Func>(async () => + { + Assert.Fail(); + await Task.Delay(100); + return "Optional Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(testFunction) + }); + + var context = PrepareContext(); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + } + + [Test] + [Description("Test if the optional async data is fetched properly with specified partial props.")] + public void TestOptionalAsyncPartialData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Optional Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testOptional" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testOptional", "Optional Async" }, + { "errors", new Dictionary(0) } + })); + } +} From 1d74592da708cb2346e303c1a797a73b50e2a97d Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 00:44:50 -0500 Subject: [PATCH 13/34] added merge prop --- InertiaCore/Inertia.cs | 6 ++++++ InertiaCore/Models/Page.cs | 1 + InertiaCore/Response.cs | 30 ++++++++++++++++++++++++++++++ InertiaCore/ResponseFactory.cs | 6 ++++++ InertiaCore/Utils/Header.cs | 2 ++ InertiaCore/Utils/MergeProp.cs | 33 +++++++++++++++++++++++++++++++++ InertiaCore/Utils/Mergeable.cs | 15 +++++++++++++++ 7 files changed, 93 insertions(+) create mode 100644 InertiaCore/Utils/MergeProp.cs create mode 100644 InertiaCore/Utils/Mergeable.cs diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index 8519b53..aa87a2f 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -34,6 +34,12 @@ public static class Inertia public static AlwaysProp Always(Func> callback) => _factory.Always(callback); + public static MergeProp Merge(object? value) => _factory.Merge(value); + + public static MergeProp Merge(Func callback) => _factory.Merge(callback); + + public static MergeProp Merge(Func> callback) => _factory.Merge(callback); + public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback); diff --git a/InertiaCore/Models/Page.cs b/InertiaCore/Models/Page.cs index 47abba7..230462a 100644 --- a/InertiaCore/Models/Page.cs +++ b/InertiaCore/Models/Page.cs @@ -6,4 +6,5 @@ internal class Page public string Component { get; set; } = default!; public string? Version { get; set; } public string Url { get; set; } = default!; + public List? MergeProps { get; set; } } diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 87b53da..1125530 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -41,6 +41,8 @@ protected internal void ProcessResponse() Props = ResolveProperties(_props.GetType().GetProperties().ToDictionary(o => o.Name.ToCamelCase(), o => o.GetValue(_props))) }; + page.MergeProps = ResolveMergeProps(page.Props); + var shared = _context!.HttpContext.Features.Get(); if (shared != null) page.Props = shared.GetMerged(page.Props); @@ -57,6 +59,7 @@ protected internal void ProcessResponse() Func f => f.Invoke(), LazyProp l => l.Invoke(), AlwaysProp l => l.Invoke(), + MergeProp m => m.Invoke(), _ => pair.Value }); } @@ -158,4 +161,31 @@ public Response WithViewData(IDictionary viewData) .Where(kv => kv.Value is not AlwaysProp) .Concat(alwaysProps).ToDictionary(kv => kv.Key, kv => kv.Value); } + + private List? ResolveMergeProps(Dictionary props) + { + // Parse the "RESET" header into a collection of keys to reset + var resetProps = new HashSet( + _context!.HttpContext.Request.Headers[Header.Reset] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()), + StringComparer.OrdinalIgnoreCase + ); + + var resolvedProps = props + .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase + .ToList(); + + // Filter the props that are Mergeable and should be merged + var mergeProps = _props.GetType().GetProperties().ToDictionary(o => o.Name.ToCamelCase(), o => o.GetValue(_props)) + .Where(kv => kv.Value is Mergeable mergeable && mergeable.ShouldMerge()) // Check if value is Mergeable and should merge + .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys + .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase + .Where(resolvedProps.Contains) // Filter only the props that are in the resolved props + .ToList(); + + // Return the result + return mergeProps; + } } diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 288721e..e95f10d 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -26,6 +26,9 @@ internal interface IResponseFactory public AlwaysProp Always(Func> callback); public LazyProp Lazy(Func callback); public LazyProp Lazy(Func> callback); + public MergeProp Merge(object? value); + public MergeProp Merge(Func callback); + public MergeProp Merge(Func> callback); } internal class ResponseFactory : IResponseFactory @@ -127,4 +130,7 @@ public void Share(IDictionary data) public AlwaysProp Always(object? value) => new AlwaysProp(value); public AlwaysProp Always(Func callback) => new AlwaysProp(callback); public AlwaysProp Always(Func> callback) => new AlwaysProp(callback); + public MergeProp Merge(object? value) => new MergeProp(value); + public MergeProp Merge(Func callback) => new MergeProp(callback); + public MergeProp Merge(Func> callback) => new MergeProp(callback); } diff --git a/InertiaCore/Utils/Header.cs b/InertiaCore/Utils/Header.cs index b729dbf..b1f9abb 100644 --- a/InertiaCore/Utils/Header.cs +++ b/InertiaCore/Utils/Header.cs @@ -15,4 +15,6 @@ public static class Header public const string PartialOnly = "X-Inertia-Partial-Data"; public const string PartialExcept = "X-Inertia-Partial-Except"; + + public const string Reset = "X-Inertia-Reset"; } diff --git a/InertiaCore/Utils/MergeProp.cs b/InertiaCore/Utils/MergeProp.cs new file mode 100644 index 0000000..c26b7ad --- /dev/null +++ b/InertiaCore/Utils/MergeProp.cs @@ -0,0 +1,33 @@ +namespace InertiaCore.Utils; + +public class MergeProp : Mergeable +{ + public bool merge { get; set; } = true; + + private readonly object? _value; + + public MergeProp(object? value) + { + _value = value; + merge = true; + } + + public object? Invoke() + { + // Check if the value is a callable delegate + return Task.Run(async () => + { + if (_value is Func> asyncCallable) + { + return await asyncCallable.Invoke(); + } + + if (_value is Func callable) + { + return callable.Invoke(); + } + + return _value; + }).GetAwaiter().GetResult(); + } +} diff --git a/InertiaCore/Utils/Mergeable.cs b/InertiaCore/Utils/Mergeable.cs new file mode 100644 index 0000000..b7e5b6e --- /dev/null +++ b/InertiaCore/Utils/Mergeable.cs @@ -0,0 +1,15 @@ +namespace InertiaCore.Utils; + +public interface Mergeable +{ + public bool merge { get; set; } + + public Mergeable Merge() + { + merge = true; + + return this; + } + + public bool ShouldMerge() => merge; +} From 05bf42d85f5485638f29b7f729666e5432a54265 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 14:40:50 -0500 Subject: [PATCH 14/34] added unit test for merge prop --- InertiaCoreTests/UnitTestMergeData.cs | 206 ++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 InertiaCoreTests/UnitTestMergeData.cs diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs new file mode 100644 index 0000000..a6e9312 --- /dev/null +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -0,0 +1,206 @@ +using InertiaCore.Models; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the merge data is fetched properly.")] + public void TestMergeData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(() => + { + return "Merge"; + }) + }); + + var context = PrepareContext(); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testMerge", "Merge" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge data is fetched properly with specified partial props.")] + public void TestMergePartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(() => "Merge") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testMerge" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testMerge", "Merge" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge async data is fetched properly.")] + public void TestMergeAsyncData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(testFunction) + }); + + var context = PrepareContext(); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testMerge", "Merge Async" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge async data is fetched properly with specified partial props.")] + public void TestMergeAsyncPartialData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testMerge" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testMerge", "Merge Async" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge async data is fetched properly without specified partial props.")] + public void TestMergeAsyncPartialDataOmitted() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { })); + } + + public void TestNoMergeProps() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + }); + + var context = PrepareContext(); + + response.SetContext(context); + response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.MergeProps, Is.EqualTo(new List { })); + } +} From 3cacdaa9bd86580b382e74282034306697dfb6e8 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 14:52:03 -0500 Subject: [PATCH 15/34] update version in actions --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 25f43e7..235f1ef 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -19,7 +19,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.0.x + dotnet-version: 9.0.x - name: Restore dependencies run: dotnet restore - name: Build From 52ad4b83296b667a599eaee30c7f1c8e32a03d99 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 14:52:03 -0500 Subject: [PATCH 16/34] update version in actions --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 25f43e7..235f1ef 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -19,7 +19,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 6.0.x + dotnet-version: 9.0.x - name: Restore dependencies run: dotnet restore - name: Build From 1e1869695e88d335a0a3451f4eeb40df362e334c Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 15:19:04 -0500 Subject: [PATCH 17/34] dont resolve Lazy and Optional Props until they are invoked --- InertiaCore/Utils/LazyProp.cs | 25 +++++++++++++++++++++---- InertiaCore/Utils/OptionalProp.cs | 24 ++++++++++++++++++++---- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/InertiaCore/Utils/LazyProp.cs b/InertiaCore/Utils/LazyProp.cs index 46004b5..7676e6c 100644 --- a/InertiaCore/Utils/LazyProp.cs +++ b/InertiaCore/Utils/LazyProp.cs @@ -2,10 +2,27 @@ namespace InertiaCore.Utils; public class LazyProp : IgnoreFirstLoad { - private readonly Func> _callback; + private readonly object? _value; - public LazyProp(Func callback) => _callback = async () => await Task.FromResult(callback()); - public LazyProp(Func> callback) => _callback = callback; + public LazyProp(Func callback) => _value = callback; + public LazyProp(Func> callback) => _value = callback; - public object? Invoke() => Task.Run(() => _callback.Invoke()).GetAwaiter().GetResult(); + public object? Invoke() + { + // Check if the value is a callable delegate + return Task.Run(async () => + { + if (_value is Func> asyncCallable) + { + return await asyncCallable.Invoke(); + } + + if (_value is Func callable) + { + return callable.Invoke(); + } + + return _value; + }).GetAwaiter().GetResult(); + } } diff --git a/InertiaCore/Utils/OptionalProp.cs b/InertiaCore/Utils/OptionalProp.cs index 20c6e5a..7818b17 100644 --- a/InertiaCore/Utils/OptionalProp.cs +++ b/InertiaCore/Utils/OptionalProp.cs @@ -2,11 +2,27 @@ namespace InertiaCore.Utils; public class OptionalProp : IgnoreFirstLoad { - private readonly Func> _callback; + private readonly object? _value; - public OptionalProp(Func callback) => _callback = async () => await Task.FromResult(callback()); + public OptionalProp(Func callback) => _value = callback; + public OptionalProp(Func> callback) => _value = callback; - public OptionalProp(Func> callback) => _callback = callback; + public object? Invoke() + { + // Check if the value is a callable delegate + return Task.Run(async () => + { + if (_value is Func> asyncCallable) + { + return await asyncCallable.Invoke(); + } - public object? Invoke() => Task.Run(() => _callback.Invoke()).GetAwaiter().GetResult(); + if (_value is Func callable) + { + return callable.Invoke(); + } + + return _value; + }).GetAwaiter().GetResult(); + } } From 7860eea4159556897454c13c5f60a03e3c09f3d2 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 16:11:43 -0500 Subject: [PATCH 18/34] dont include Merge props in the json --- InertiaCore/Models/Page.cs | 4 ++ InertiaCore/Response.cs | 5 +++ InertiaCoreTests/UnitTestMergeData.cs | 5 ++- InertiaCoreTests/UnitTestResult.cs | 57 +++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/InertiaCore/Models/Page.cs b/InertiaCore/Models/Page.cs index 230462a..93ab0a2 100644 --- a/InertiaCore/Models/Page.cs +++ b/InertiaCore/Models/Page.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace InertiaCore.Models; internal class Page @@ -6,5 +8,7 @@ internal class Page public string Component { get; set; } = default!; public string? Version { get; set; } public string Url { get; set; } = default!; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? MergeProps { get; set; } } diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 1125530..5c1e0e4 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -185,6 +185,11 @@ public Response WithViewData(IDictionary viewData) .Where(resolvedProps.Contains) // Filter only the props that are in the resolved props .ToList(); + if (mergeProps.Count == 0) + { + return null; + } + // Return the result return mergeProps; } diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs index a6e9312..6f35a0c 100644 --- a/InertiaCoreTests/UnitTestMergeData.cs +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -177,7 +177,7 @@ public void TestMergeAsyncPartialDataOmitted() { "errors", new Dictionary(0) } })); - Assert.That(page?.MergeProps, Is.EqualTo(new List { })); + Assert.That(page?.MergeProps, Is.EqualTo(null)); } public void TestNoMergeProps() @@ -201,6 +201,7 @@ public void TestNoMergeProps() { "testFunc", "Func" }, { "errors", new Dictionary(0) } })); - Assert.That(page?.MergeProps, Is.EqualTo(new List { })); + Assert.That(page?.MergeProps, Is.EqualTo(null)); } + } diff --git a/InertiaCoreTests/UnitTestResult.cs b/InertiaCoreTests/UnitTestResult.cs index f3ff8c0..38cfa34 100644 --- a/InertiaCoreTests/UnitTestResult.cs +++ b/InertiaCoreTests/UnitTestResult.cs @@ -1,6 +1,7 @@ using InertiaCore.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using System.Text.Json; namespace InertiaCoreTests; @@ -40,6 +41,62 @@ public void TestJsonResult() { "test", "Test" }, { "errors", new Dictionary(0) } })); + + // Check the serialized JSON + var jsonString = JsonSerializer.Serialize(json); + var dictionary = JsonSerializer.Deserialize>(jsonString); + + Assert.That(dictionary, Is.Not.Null); + Assert.That(dictionary!.ContainsKey("MergeProps"), Is.False); + }); + } + [ + Test] + [Description("Test if the JSON result with merged data is created correctly.")] + public void TestJsonMergedResult() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerged = _factory.Merge(() => "Merged") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + response.ProcessResponse(); + + var result = response.GetResult(); + + Assert.Multiple(() => + { + Assert.That(result, Is.InstanceOf(typeof(JsonResult))); + + var json = (result as JsonResult)?.Value; + Assert.That(json, Is.InstanceOf(typeof(Page))); + + Assert.That((json as Page)?.Component, Is.EqualTo("Test/Page")); + Assert.That((json as Page)?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testMerged", "Merged" }, + { "errors", new Dictionary(0) } + })); + Assert.That((json as Page)?.MergeProps, Is.EqualTo(new List { + "testMerged" + })); + + // Check the serialized JSON + var jsonString = JsonSerializer.Serialize(json); + var dictionary = JsonSerializer.Deserialize>(jsonString); + + Assert.That(dictionary, Is.Not.Null); + Assert.That(dictionary!.ContainsKey("MergeProps"), Is.True); }); } From 21a8f65ec643955981362902bb448757d817d53f Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 21 Dec 2024 17:04:05 -0500 Subject: [PATCH 19/34] fix formatting --- InertiaCoreTests/UnitTestResult.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InertiaCoreTests/UnitTestResult.cs b/InertiaCoreTests/UnitTestResult.cs index 38cfa34..38988bb 100644 --- a/InertiaCoreTests/UnitTestResult.cs +++ b/InertiaCoreTests/UnitTestResult.cs @@ -50,8 +50,8 @@ public void TestJsonResult() Assert.That(dictionary!.ContainsKey("MergeProps"), Is.False); }); } - [ - Test] + + [Test] [Description("Test if the JSON result with merged data is created correctly.")] public void TestJsonMergedResult() { From cf2c45e05050c3c597c2d3e0dc4acb573833af47 Mon Sep 17 00:00:00 2001 From: kapi2289 Date: Mon, 6 Jan 2025 18:00:31 +0100 Subject: [PATCH 20/34] Minor fixes and changes --- InertiaCore/Props/LazyProp.cs | 23 +++++++++-------------- InertiaCore/Props/OptionalProp.cs | 23 +++++++++-------------- InertiaCore/Response.cs | 2 +- InertiaCore/Utils/IIgnoresFirstLoad.cs | 5 +++++ InertiaCore/Utils/IgnoreFirstLoad.cs | 6 ------ 5 files changed, 24 insertions(+), 35 deletions(-) create mode 100644 InertiaCore/Utils/IIgnoresFirstLoad.cs delete mode 100644 InertiaCore/Utils/IgnoreFirstLoad.cs diff --git a/InertiaCore/Props/LazyProp.cs b/InertiaCore/Props/LazyProp.cs index ade983e..8254830 100644 --- a/InertiaCore/Props/LazyProp.cs +++ b/InertiaCore/Props/LazyProp.cs @@ -2,7 +2,7 @@ namespace InertiaCore.Props; -public class LazyProp : IgnoreFirstLoad +public class LazyProp : IIgnoresFirstLoad { private readonly object? _value; @@ -13,18 +13,13 @@ public class LazyProp : IgnoreFirstLoad { // Check if the value is a callable delegate return Task.Run(async () => - { - if (_value is Func> asyncCallable) - { - return await asyncCallable.Invoke(); - } - - if (_value is Func callable) - { - return callable.Invoke(); - } - - return _value; - }).GetAwaiter().GetResult(); + { + return _value switch + { + Func> asyncCallable => await asyncCallable.Invoke(), + Func callable => callable.Invoke(), + _ => _value + }; + }).GetAwaiter().GetResult(); } } diff --git a/InertiaCore/Props/OptionalProp.cs b/InertiaCore/Props/OptionalProp.cs index d084786..9709b4f 100644 --- a/InertiaCore/Props/OptionalProp.cs +++ b/InertiaCore/Props/OptionalProp.cs @@ -2,7 +2,7 @@ namespace InertiaCore.Props; -public class OptionalProp : IgnoreFirstLoad +public class OptionalProp : IIgnoresFirstLoad { private readonly object? _value; @@ -13,18 +13,13 @@ public class OptionalProp : IgnoreFirstLoad { // Check if the value is a callable delegate return Task.Run(async () => - { - if (_value is Func> asyncCallable) - { - return await asyncCallable.Invoke(); - } - - if (_value is Func callable) - { - return callable.Invoke(); - } - - return _value; - }).GetAwaiter().GetResult(); + { + return _value switch + { + Func> asyncCallable => await asyncCallable.Invoke(), + Func callable => callable.Invoke(), + _ => _value + }; + }).GetAwaiter().GetResult(); } } diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index e071667..890bc5c 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -119,7 +119,7 @@ public Response WithViewData(IDictionary viewData) if (!isPartial) { props = props - .Where(kv => (kv.Value as IgnoreFirstLoad) == null) + .Where(kv => kv.Value is not IIgnoresFirstLoad) .ToDictionary(kv => kv.Key, kv => kv.Value); } else diff --git a/InertiaCore/Utils/IIgnoresFirstLoad.cs b/InertiaCore/Utils/IIgnoresFirstLoad.cs new file mode 100644 index 0000000..10fc9ba --- /dev/null +++ b/InertiaCore/Utils/IIgnoresFirstLoad.cs @@ -0,0 +1,5 @@ +namespace InertiaCore.Utils; + +public interface IIgnoresFirstLoad +{ +} diff --git a/InertiaCore/Utils/IgnoreFirstLoad.cs b/InertiaCore/Utils/IgnoreFirstLoad.cs deleted file mode 100644 index f5ff0f7..0000000 --- a/InertiaCore/Utils/IgnoreFirstLoad.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace InertiaCore.Utils; - -public interface IgnoreFirstLoad -{ - -} From 42396a772c0efdf572f262ae7775c499427e58fb Mon Sep 17 00:00:00 2001 From: kapi2289 Date: Mon, 6 Jan 2025 18:04:20 +0100 Subject: [PATCH 21/34] Add missing Inertia static methods --- InertiaCore/Inertia.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index 9220dc6..6491693 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -29,13 +29,17 @@ public static class Inertia public static void Share(IDictionary data) => _factory.Share(data); + public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); + + public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback); + public static AlwaysProp Always(object? value) => _factory.Always(value); public static AlwaysProp Always(Func callback) => _factory.Always(callback); public static AlwaysProp Always(Func> callback) => _factory.Always(callback); - public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); + public static OptionalProp Optional(Func callback) => _factory.Optional(callback); - public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback); + public static OptionalProp Optional(Func> callback) => _factory.Optional(callback); } From ea4592ee5ae4abefd9af255d8681be37133bd1e7 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jan 2025 21:32:27 -0500 Subject: [PATCH 22/34] make optional prop invokable --- InertiaCore/Props/OptionalProp.cs | 21 +++++---------------- InertiaCoreTests/UnitTestOptionalData.cs | 16 ++++++++-------- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/InertiaCore/Props/OptionalProp.cs b/InertiaCore/Props/OptionalProp.cs index 9709b4f..cf9c971 100644 --- a/InertiaCore/Props/OptionalProp.cs +++ b/InertiaCore/Props/OptionalProp.cs @@ -2,24 +2,13 @@ namespace InertiaCore.Props; -public class OptionalProp : IIgnoresFirstLoad +public class OptionalProp : InvokableProp, IIgnoresFirstLoad { - private readonly object? _value; - - public OptionalProp(Func callback) => _value = callback; - public OptionalProp(Func> callback) => _value = callback; + internal OptionalProp(Func value) : base(value) + { + } - public object? Invoke() + internal OptionalProp(Func> value) : base(value) { - // Check if the value is a callable delegate - return Task.Run(async () => - { - return _value switch - { - Func> asyncCallable => await asyncCallable.Invoke(), - Func callable => callable.Invoke(), - _ => _value - }; - }).GetAwaiter().GetResult(); } } diff --git a/InertiaCoreTests/UnitTestOptionalData.cs b/InertiaCoreTests/UnitTestOptionalData.cs index 8d8365f..3d925ec 100644 --- a/InertiaCoreTests/UnitTestOptionalData.cs +++ b/InertiaCoreTests/UnitTestOptionalData.cs @@ -7,7 +7,7 @@ public partial class Tests { [Test] [Description("Test if the optional data is fetched properly.")] - public void TestOptionalData() + public async Task TestOptionalData() { var response = _factory.Render("Test/Page", new { @@ -23,7 +23,7 @@ public void TestOptionalData() var context = PrepareContext(); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; @@ -37,7 +37,7 @@ public void TestOptionalData() [Test] [Description("Test if the optional data is fetched properly with specified partial props.")] - public void TestOptionalPartialData() + public async Task TestOptionalPartialData() { var response = _factory.Render("Test/Page", new { @@ -54,7 +54,7 @@ public void TestOptionalPartialData() var context = PrepareContext(headers); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; @@ -69,7 +69,7 @@ public void TestOptionalPartialData() [Test] [Description("Test if the optional async data is fetched properly.")] - public void TestOptionalAsyncData() + public async Task TestOptionalAsyncData() { var testFunction = new Func>(async () => { @@ -88,7 +88,7 @@ public void TestOptionalAsyncData() var context = PrepareContext(); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; @@ -102,7 +102,7 @@ public void TestOptionalAsyncData() [Test] [Description("Test if the optional async data is fetched properly with specified partial props.")] - public void TestOptionalAsyncPartialData() + public async Task TestOptionalAsyncPartialData() { var testFunction = new Func>(async () => { @@ -125,7 +125,7 @@ public void TestOptionalAsyncPartialData() var context = PrepareContext(headers); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; From 1e949b0db5db84d2429c4593ea01aac783bab8c9 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jan 2025 21:42:03 -0500 Subject: [PATCH 23/34] fix merge prop tests --- InertiaCore/Props/MergeProp.cs | 25 ++++++++++++++++++++ InertiaCore/Utils/MergeProp.cs | 33 --------------------------- InertiaCoreTests/UnitTestMergeData.cs | 24 +++++++++---------- InertiaCoreTests/UnitTestResult.cs | 4 ++-- 4 files changed, 39 insertions(+), 47 deletions(-) create mode 100644 InertiaCore/Props/MergeProp.cs delete mode 100644 InertiaCore/Utils/MergeProp.cs diff --git a/InertiaCore/Props/MergeProp.cs b/InertiaCore/Props/MergeProp.cs new file mode 100644 index 0000000..6d41883 --- /dev/null +++ b/InertiaCore/Props/MergeProp.cs @@ -0,0 +1,25 @@ +using InertiaCore.Props; + +namespace InertiaCore.Utils; + +public class MergeProp : InvokableProp, Mergeable +{ + public bool merge { get; set; } = true; + + public MergeProp(object? value) : base(value) + { + merge = true; + } + + internal MergeProp(Func value) : base(value) + { + merge = true; + } + + internal MergeProp(Func> value) : base(value) + { + merge = true; + } +} + + diff --git a/InertiaCore/Utils/MergeProp.cs b/InertiaCore/Utils/MergeProp.cs deleted file mode 100644 index c26b7ad..0000000 --- a/InertiaCore/Utils/MergeProp.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace InertiaCore.Utils; - -public class MergeProp : Mergeable -{ - public bool merge { get; set; } = true; - - private readonly object? _value; - - public MergeProp(object? value) - { - _value = value; - merge = true; - } - - public object? Invoke() - { - // Check if the value is a callable delegate - return Task.Run(async () => - { - if (_value is Func> asyncCallable) - { - return await asyncCallable.Invoke(); - } - - if (_value is Func callable) - { - return callable.Invoke(); - } - - return _value; - }).GetAwaiter().GetResult(); - } -} diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs index 6f35a0c..844aaeb 100644 --- a/InertiaCoreTests/UnitTestMergeData.cs +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -7,7 +7,7 @@ public partial class Tests { [Test] [Description("Test if the merge data is fetched properly.")] - public void TestMergeData() + public async Task TestMergeData() { var response = _factory.Render("Test/Page", new { @@ -22,7 +22,7 @@ public void TestMergeData() var context = PrepareContext(); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; @@ -38,7 +38,7 @@ public void TestMergeData() [Test] [Description("Test if the merge data is fetched properly with specified partial props.")] - public void TestMergePartialData() + public async Task TestMergePartialData() { var response = _factory.Render("Test/Page", new { @@ -55,7 +55,7 @@ public void TestMergePartialData() var context = PrepareContext(headers); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; @@ -71,7 +71,7 @@ public void TestMergePartialData() [Test] [Description("Test if the merge async data is fetched properly.")] - public void TestMergeAsyncData() + public async Task TestMergeAsyncData() { var testFunction = new Func>(async () => { @@ -89,7 +89,7 @@ public void TestMergeAsyncData() var context = PrepareContext(); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; @@ -105,7 +105,7 @@ public void TestMergeAsyncData() [Test] [Description("Test if the merge async data is fetched properly with specified partial props.")] - public void TestMergeAsyncPartialData() + public async Task TestMergeAsyncPartialData() { var testFunction = new Func>(async () => { @@ -128,7 +128,7 @@ public void TestMergeAsyncPartialData() var context = PrepareContext(headers); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; @@ -144,7 +144,7 @@ public void TestMergeAsyncPartialData() [Test] [Description("Test if the merge async data is fetched properly without specified partial props.")] - public void TestMergeAsyncPartialDataOmitted() + public async Task TestMergeAsyncPartialDataOmitted() { var testFunction = new Func>(async () => { @@ -167,7 +167,7 @@ public void TestMergeAsyncPartialDataOmitted() var context = PrepareContext(headers); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; @@ -180,7 +180,7 @@ public void TestMergeAsyncPartialDataOmitted() Assert.That(page?.MergeProps, Is.EqualTo(null)); } - public void TestNoMergeProps() + public async Task TestNoMergeProps() { var response = _factory.Render("Test/Page", new { @@ -191,7 +191,7 @@ public void TestNoMergeProps() var context = PrepareContext(); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var page = response.GetJson().Value as Page; diff --git a/InertiaCoreTests/UnitTestResult.cs b/InertiaCoreTests/UnitTestResult.cs index 8fb08c6..36e4cc9 100644 --- a/InertiaCoreTests/UnitTestResult.cs +++ b/InertiaCoreTests/UnitTestResult.cs @@ -53,7 +53,7 @@ public async Task TestJsonResult() [Test] [Description("Test if the JSON result with merged data is created correctly.")] - public void TestJsonMergedResult() + public async Task TestJsonMergedResult() { var response = _factory.Render("Test/Page", new { @@ -69,7 +69,7 @@ public void TestJsonMergedResult() var context = PrepareContext(headers); response.SetContext(context); - response.ProcessResponse(); + await response.ProcessResponse(); var result = response.GetResult(); From ad8c4762589386ad9d85f63a29b10effc0859152 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jan 2025 21:49:12 -0500 Subject: [PATCH 24/34] fix test sdks? --- .github/workflows/dotnet.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b78f820..599ad5b 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -23,7 +23,11 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 9.0.x + dotnet-version: | + 6.0.x + 7.0.x + 8.0.x + 9.0.x - name: Restore dependencies run: dotnet restore - name: Build From 6333daa445558a6967f280585658af5230154353 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 10 Jan 2025 21:49:12 -0500 Subject: [PATCH 25/34] fix test sdks? --- .github/workflows/dotnet.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b78f820..599ad5b 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -23,7 +23,11 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 9.0.x + dotnet-version: | + 6.0.x + 7.0.x + 8.0.x + 9.0.x - name: Restore dependencies run: dotnet restore - name: Build From 2ae0bffe995101e9fd114729a76bd50fe823bcbc Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 8 Feb 2025 11:31:14 -0500 Subject: [PATCH 26/34] revert formatting --- .github/workflows/dotnet.yml | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 6788a6d..dfea3e2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -15,21 +15,22 @@ on: jobs: build: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: | - 6.0.x - 7.0.x - 8.0.x - 9.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 6.0.x + 7.0.x + 8.0.x + 9.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal From 0e9353c18d22d15247f64eed354f15d2d0f7c8e1 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 8 Feb 2025 11:31:39 -0500 Subject: [PATCH 27/34] one more formatting fix --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index dfea3e2..899c0f2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -15,7 +15,7 @@ on: jobs: build: - + runs-on: ubuntu-latest steps: From d842ac824e405cccc62b8224749463808411e8df Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 20 Sep 2025 13:02:26 -0400 Subject: [PATCH 28/34] [2.x] Keep only partial data in mergeProps https://github.com/inertiajs/inertia-laravel/pull/745 --- InertiaCore/Response.cs | 19 +++++ InertiaCoreTests/UnitTestMergeData.cs | 112 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index b6779a4..9fd7cc3 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -159,6 +159,23 @@ protected internal async Task ProcessResponse() StringComparer.OrdinalIgnoreCase ); + // Parse the "PARTIAL_ONLY" header into a collection of keys to include + var onlyProps = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Parse the "PARTIAL_EXCEPT" header into a collection of keys to exclude + var exceptProps = new HashSet( + _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()), + StringComparer.OrdinalIgnoreCase + ); + var resolvedProps = props .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase .ToList(); @@ -166,6 +183,8 @@ protected internal async Task ProcessResponse() // Filter the props that are Mergeable and should be merged var mergeProps = _props.Where(o => o.Value is Mergeable mergeable && mergeable.ShouldMerge()) // Check if value is Mergeable and should merge .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys + .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any + .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified keys .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase .Where(resolvedProps.Contains) // Filter only the props that are in the resolved props .ToList(); diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs index 7065f8d..2a09a07 100644 --- a/InertiaCoreTests/UnitTestMergeData.cs +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -206,4 +206,116 @@ public async Task TestNoMergeProps() Assert.That(page?.MergeProps, Is.EqualTo(null)); } + [Test] + [Description("Test if merge props are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestMergePropsWithPartialExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Except", "testMerge1" }, + { "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 + { + { "test", "Test" }, + { "testMerge2", "Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // testMerge1 should be excluded from merge props due to PARTIAL_EXCEPT header + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); + } + + [Test] + [Description("Test if only specified merge props are included when using PARTIAL_ONLY header.")] + public async Task TestMergePropsWithPartialOnly() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testMerge1,testNormal" }, + { "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 + { + { "testMerge1", "Merge1" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // Only testMerge1 should be in merge props since testMerge2 was not included in PARTIAL_ONLY + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1" })); + } + + [Test] + [Description("Test if merge props respect both PARTIAL_ONLY and PARTIAL_EXCEPT headers.")] + public async Task TestMergePropsWithPartialOnlyAndExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + TestMerge3 = _factory.Merge(() => "Merge3"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testMerge1,testMerge2,testMerge3,testNormal" }, + { "X-Inertia-Partial-Except", "testMerge2" }, + { "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 + { + { "testMerge1", "Merge1" }, + { "testMerge3", "Merge3" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // testMerge1 and testMerge3 should be in merge props (testMerge2 excluded by PARTIAL_EXCEPT) + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge3" })); + } + } From 58913c939cca09f38f24732483927c2e9839a3b9 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sat, 20 Sep 2025 16:46:14 -0400 Subject: [PATCH 29/34] [2.x] Allow deepMerge on custom properties https://github.com/inertiajs/inertia-laravel/pull/732 https://github.com/inertiajs/inertia/pull/2344 --- InertiaCore/Inertia.cs | 12 +++ InertiaCore/Models/Page.cs | 3 + InertiaCore/Props/MergeProp.cs | 37 +++++++ InertiaCore/Response.cs | 56 ++++++++++ InertiaCore/ResponseFactory.cs | 12 +++ InertiaCore/Utils/Mergeable.cs | 2 + InertiaCoreTests/UnitTestMergeData.cs | 145 ++++++++++++++++++++++++++ 7 files changed, 267 insertions(+) diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index 81f88c6..6bd0ba4 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -50,4 +50,16 @@ public static class Inertia public static MergeProp Merge(Func callback) => _factory.Merge(callback); public static MergeProp Merge(Func> callback) => _factory.Merge(callback); + + public static MergeProp Merge(object? value, string strategy) => _factory.Merge(value, strategy); + + public static MergeProp Merge(object? value, string[]? strategies) => _factory.Merge(value, strategies); + + public static MergeProp Merge(Func callback, string strategy) => _factory.Merge(callback, strategy); + + public static MergeProp Merge(Func callback, string[]? strategies) => _factory.Merge(callback, strategies); + + public static MergeProp Merge(Func> callback, string strategy) => _factory.Merge(callback, strategy); + + public static MergeProp Merge(Func> callback, string[]? strategies) => _factory.Merge(callback, strategies); } diff --git a/InertiaCore/Models/Page.cs b/InertiaCore/Models/Page.cs index d64b751..cf5e4f9 100644 --- a/InertiaCore/Models/Page.cs +++ b/InertiaCore/Models/Page.cs @@ -13,4 +13,7 @@ internal class Page [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? MergeProps { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? MergeStrategies { get; set; } } diff --git a/InertiaCore/Props/MergeProp.cs b/InertiaCore/Props/MergeProp.cs index 6d41883..69d943f 100644 --- a/InertiaCore/Props/MergeProp.cs +++ b/InertiaCore/Props/MergeProp.cs @@ -5,21 +5,58 @@ namespace InertiaCore.Utils; public class MergeProp : InvokableProp, Mergeable { public bool merge { get; set; } = true; + public string[]? mergeStrategies { get; set; } public MergeProp(object? value) : base(value) { merge = true; } + public MergeProp(object? value, string[]? strategies) : base(value) + { + merge = true; + mergeStrategies = strategies; + } + + public MergeProp(object? value, string strategy) : base(value) + { + merge = true; + mergeStrategies = new[] { strategy }; + } + internal MergeProp(Func value) : base(value) { merge = true; } + internal MergeProp(Func value, string[]? strategies) : base(value) + { + merge = true; + mergeStrategies = strategies; + } + + internal MergeProp(Func value, string strategy) : base(value) + { + merge = true; + mergeStrategies = new[] { strategy }; + } + internal MergeProp(Func> value) : base(value) { merge = true; } + + internal MergeProp(Func> value, string[]? strategies) : base(value) + { + merge = true; + mergeStrategies = strategies; + } + + internal MergeProp(Func> value, string strategy) : base(value) + { + merge = true; + mergeStrategies = new[] { strategy }; + } } diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 9fd7cc3..398fae3 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -48,6 +48,7 @@ protected internal async Task ProcessResponse() }; page.MergeProps = ResolveMergeProps(props); + page.MergeStrategies = ResolveMergeStrategies(props); page.Props["errors"] = GetErrors(); SetPage(page); @@ -198,6 +199,61 @@ protected internal async Task ProcessResponse() return mergeProps; } + /// + /// Resolve merge strategies for properties that should be merged with custom strategies. + /// + private Dictionary? ResolveMergeStrategies(Dictionary props) + { + // Parse the "RESET" header into a collection of keys to reset + var resetProps = new HashSet( + _context!.HttpContext.Request.Headers[InertiaHeader.Reset] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()), + StringComparer.OrdinalIgnoreCase + ); + + // Parse the "PARTIAL_ONLY" header into a collection of keys to include + var onlyProps = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Parse the "PARTIAL_EXCEPT" header into a collection of keys to exclude + var exceptProps = new HashSet( + _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()), + StringComparer.OrdinalIgnoreCase + ); + + var resolvedProps = props + .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase + .ToList(); + + // Filter the props that have merge strategies + var mergeStrategies = _props.Where(o => o.Value is Mergeable mergeable && mergeable.ShouldMerge() && mergeable.GetMergeStrategies() != null) + .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys + .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any + .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified keys + .Where(kv => resolvedProps.Contains(kv.Key.ToCamelCase())) // Filter only the props that are in the resolved props + .ToDictionary( + kv => kv.Key.ToCamelCase(), // Convert property name to camelCase + kv => ((Mergeable)kv.Value!).GetMergeStrategies()! + ); + + if (mergeStrategies.Count == 0) + { + return null; + } + + // Return the result + return mergeStrategies; + } + /// /// Resolve all necessary class instances in the given props. /// diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 62b71a0..405080d 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -32,6 +32,12 @@ internal interface IResponseFactory public MergeProp Merge(object? value); public MergeProp Merge(Func callback); public MergeProp Merge(Func> callback); + public MergeProp Merge(object? value, string strategy); + public MergeProp Merge(object? value, string[]? strategies); + public MergeProp Merge(Func callback, string strategy); + public MergeProp Merge(Func callback, string[]? strategies); + public MergeProp Merge(Func> callback, string strategy); + public MergeProp Merge(Func> callback, string[]? strategies); public OptionalProp Optional(Func callback); public OptionalProp Optional(Func> callback); } @@ -152,6 +158,12 @@ public void Share(IDictionary data) public MergeProp Merge(object? value) => new(value); public MergeProp Merge(Func callback) => new(callback); public MergeProp Merge(Func> callback) => new(callback); + public MergeProp Merge(object? value, string strategy) => new(value, strategy); + public MergeProp Merge(object? value, string[]? strategies) => new(value, strategies); + public MergeProp Merge(Func callback, string strategy) => new(callback, strategy); + public MergeProp Merge(Func callback, string[]? strategies) => new(callback, strategies); + public MergeProp Merge(Func> callback, string strategy) => new(callback, strategy); + public MergeProp Merge(Func> callback, string[]? strategies) => new(callback, strategies); public OptionalProp Optional(Func callback) => new(callback); public OptionalProp Optional(Func> callback) => new(callback); } diff --git a/InertiaCore/Utils/Mergeable.cs b/InertiaCore/Utils/Mergeable.cs index b7e5b6e..bcd3616 100644 --- a/InertiaCore/Utils/Mergeable.cs +++ b/InertiaCore/Utils/Mergeable.cs @@ -3,6 +3,7 @@ namespace InertiaCore.Utils; public interface Mergeable { public bool merge { get; set; } + public string[]? mergeStrategies { get; set; } public Mergeable Merge() { @@ -12,4 +13,5 @@ public Mergeable Merge() } public bool ShouldMerge() => merge; + public string[]? GetMergeStrategies() => mergeStrategies; } diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs index 2a09a07..606fa2e 100644 --- a/InertiaCoreTests/UnitTestMergeData.cs +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -318,4 +318,149 @@ public async Task TestMergePropsWithPartialOnlyAndExcept() Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge3" })); } + [Test] + [Description("Test if merge strategies are resolved properly for merge props.")] + public async Task TestMergeStrategies() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge("Merge1", "deep"), + TestMerge2 = _factory.Merge(() => "Merge2", new[] { "shallow", "replace" }), + TestNormal = "Normal" + }); + + 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" }, + { "testMerge1", "Merge1" }, + { "testMerge2", "Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge2" })); + Assert.That(page?.MergeStrategies, Is.EqualTo(new Dictionary + { + { "testMerge1", new[] { "deep" } }, + { "testMerge2", new[] { "shallow", "replace" } } + })); + } + + [Test] + [Description("Test if merge strategies are handled properly with partial props.")] + public async Task TestMergeStrategiesWithPartialProps() + { + var response = _factory.Render("Test/Page", new + { + TestMerge1 = _factory.Merge("Merge1", "deep"), + TestMerge2 = _factory.Merge(() => "Merge2", new[] { "shallow", "replace" }), + TestMerge3 = _factory.Merge("Merge3", "custom") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testMerge1,testMerge3" }, + { "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 + { + { "testMerge1", "Merge1" }, + { "testMerge3", "Merge3" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge3" })); + Assert.That(page?.MergeStrategies, Is.EqualTo(new Dictionary + { + { "testMerge1", new[] { "deep" } }, + { "testMerge3", new[] { "custom" } } + })); + } + + [Test] + [Description("Test if merge strategies are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestMergeStrategiesWithPartialExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge("Merge1", "deep"), + TestMerge2 = _factory.Merge(() => "Merge2", new[] { "shallow", "replace" }), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Except", "testMerge1" }, + { "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 + { + { "test", "Test" }, + { "testMerge2", "Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); + Assert.That(page?.MergeStrategies, Is.EqualTo(new Dictionary + { + { "testMerge2", new[] { "shallow", "replace" } } + })); + } + + [Test] + [Description("Test if merge strategies are null when no merge props have strategies.")] + public async Task TestNoMergeStrategies() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge = _factory.Merge(() => "Merge"), // No strategies + TestNormal = "Normal" + }); + + 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" }, + { "testMerge", "Merge" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + Assert.That(page?.MergeStrategies, Is.EqualTo(null)); + } + } From 0d393c0cafdfbbe1a2e6fd91644d27e24589f137 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Mon, 22 Sep 2025 02:24:53 -0400 Subject: [PATCH 30/34] Add Inertia::deepMerge Method for Handling Complex Data Merges in Responses # https://github.com/inertiajs/inertia-laravel/pull/679 --- InertiaCore/Inertia.cs | 18 ++ InertiaCore/Models/Page.cs | 3 + InertiaCore/Props/DeepMergeProp.cs | 72 +++++ InertiaCore/Response.cs | 54 ++++ InertiaCore/ResponseFactory.cs | 18 ++ InertiaCoreTests/UnitTestDeepMergeData.cs | 311 ++++++++++++++++++++++ 6 files changed, 476 insertions(+) create mode 100644 InertiaCore/Props/DeepMergeProp.cs create mode 100644 InertiaCoreTests/UnitTestDeepMergeData.cs diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index 6bd0ba4..41c1a74 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -62,4 +62,22 @@ public static class Inertia public static MergeProp Merge(Func> callback, string strategy) => _factory.Merge(callback, strategy); public static MergeProp Merge(Func> callback, string[]? strategies) => _factory.Merge(callback, strategies); + + public static DeepMergeProp DeepMerge(object? value) => _factory.DeepMerge(value); + + public static DeepMergeProp DeepMerge(Func callback) => _factory.DeepMerge(callback); + + public static DeepMergeProp DeepMerge(Func> callback) => _factory.DeepMerge(callback); + + public static DeepMergeProp DeepMerge(object? value, string strategy) => _factory.DeepMerge(value, strategy); + + public static DeepMergeProp DeepMerge(object? value, string[]? strategies) => _factory.DeepMerge(value, strategies); + + public static DeepMergeProp DeepMerge(Func callback, string strategy) => _factory.DeepMerge(callback, strategy); + + public static DeepMergeProp DeepMerge(Func callback, string[]? strategies) => _factory.DeepMerge(callback, strategies); + + public static DeepMergeProp DeepMerge(Func> callback, string strategy) => _factory.DeepMerge(callback, strategy); + + public static DeepMergeProp DeepMerge(Func> callback, string[]? strategies) => _factory.DeepMerge(callback, strategies); } diff --git a/InertiaCore/Models/Page.cs b/InertiaCore/Models/Page.cs index cf5e4f9..0073e7f 100644 --- a/InertiaCore/Models/Page.cs +++ b/InertiaCore/Models/Page.cs @@ -16,4 +16,7 @@ internal class Page [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? MergeStrategies { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? DeepMergeProps { get; set; } } diff --git a/InertiaCore/Props/DeepMergeProp.cs b/InertiaCore/Props/DeepMergeProp.cs new file mode 100644 index 0000000..133db11 --- /dev/null +++ b/InertiaCore/Props/DeepMergeProp.cs @@ -0,0 +1,72 @@ +using InertiaCore.Utils; + +namespace InertiaCore.Props; + +public class DeepMergeProp : InvokableProp, Mergeable +{ + public bool merge { get; set; } = true; + public string[]? mergeStrategies { get; set; } + public bool deepMerge { get; set; } = true; + + public DeepMergeProp(object? value) : base(value) + { + merge = true; + deepMerge = true; + } + + public DeepMergeProp(object? value, string[]? strategies) : base(value) + { + merge = true; + deepMerge = true; + mergeStrategies = strategies; + } + + public DeepMergeProp(object? value, string strategy) : base(value) + { + merge = true; + deepMerge = true; + mergeStrategies = new[] { strategy }; + } + + internal DeepMergeProp(Func value) : base(value) + { + merge = true; + deepMerge = true; + } + + internal DeepMergeProp(Func value, string[]? strategies) : base(value) + { + merge = true; + deepMerge = true; + mergeStrategies = strategies; + } + + internal DeepMergeProp(Func value, string strategy) : base(value) + { + merge = true; + deepMerge = true; + mergeStrategies = new[] { strategy }; + } + + internal DeepMergeProp(Func> value) : base(value) + { + merge = true; + deepMerge = true; + } + + internal DeepMergeProp(Func> value, string[]? strategies) : base(value) + { + merge = true; + deepMerge = true; + mergeStrategies = strategies; + } + + internal DeepMergeProp(Func> value, string strategy) : base(value) + { + merge = true; + deepMerge = true; + mergeStrategies = new[] { strategy }; + } + + public bool ShouldDeepMerge() => deepMerge; +} \ No newline at end of file diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 398fae3..128a300 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -49,6 +49,7 @@ protected internal async Task ProcessResponse() page.MergeProps = ResolveMergeProps(props); page.MergeStrategies = ResolveMergeStrategies(props); + page.DeepMergeProps = ResolveDeepMergeProps(props); page.Props["errors"] = GetErrors(); SetPage(page); @@ -254,6 +255,59 @@ protected internal async Task ProcessResponse() return mergeStrategies; } + /// + /// Resolve deep merge properties that should be deeply merged with existing values by the front-end. + /// + private List? ResolveDeepMergeProps(Dictionary props) + { + // Parse the "RESET" header into a collection of keys to reset + var resetProps = new HashSet( + _context!.HttpContext.Request.Headers[InertiaHeader.Reset] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()), + StringComparer.OrdinalIgnoreCase + ); + + // Parse the "PARTIAL_ONLY" header into a collection of keys to include + var onlyProps = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Parse the "PARTIAL_EXCEPT" header into a collection of keys to exclude + var exceptProps = new HashSet( + _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()), + StringComparer.OrdinalIgnoreCase + ); + + var resolvedProps = props + .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase + .ToList(); + + // Filter the props that are DeepMergeable and should be deeply merged + var deepMergeProps = _props.Where(o => o.Value is DeepMergeProp deepMergeable && deepMergeable.ShouldDeepMerge()) // Check if value is DeepMergeProp and should deep merge + .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys + .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any + .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified keys + .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase + .Where(resolvedProps.Contains) // Filter only the props that are in the resolved props + .ToList(); + + if (deepMergeProps.Count == 0) + { + return null; + } + + // Return the result + return deepMergeProps; + } + /// /// Resolve all necessary class instances in the given props. /// diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 405080d..7689a11 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -38,6 +38,15 @@ internal interface IResponseFactory public MergeProp Merge(Func callback, string[]? strategies); public MergeProp Merge(Func> callback, string strategy); public MergeProp Merge(Func> callback, string[]? strategies); + public DeepMergeProp DeepMerge(object? value); + public DeepMergeProp DeepMerge(Func callback); + public DeepMergeProp DeepMerge(Func> callback); + public DeepMergeProp DeepMerge(object? value, string strategy); + public DeepMergeProp DeepMerge(object? value, string[]? strategies); + public DeepMergeProp DeepMerge(Func callback, string strategy); + public DeepMergeProp DeepMerge(Func callback, string[]? strategies); + public DeepMergeProp DeepMerge(Func> callback, string strategy); + public DeepMergeProp DeepMerge(Func> callback, string[]? strategies); public OptionalProp Optional(Func callback); public OptionalProp Optional(Func> callback); } @@ -164,6 +173,15 @@ public void Share(IDictionary data) public MergeProp Merge(Func callback, string[]? strategies) => new(callback, strategies); public MergeProp Merge(Func> callback, string strategy) => new(callback, strategy); public MergeProp Merge(Func> callback, string[]? strategies) => new(callback, strategies); + public DeepMergeProp DeepMerge(object? value) => new(value); + public DeepMergeProp DeepMerge(Func callback) => new(callback); + public DeepMergeProp DeepMerge(Func> callback) => new(callback); + public DeepMergeProp DeepMerge(object? value, string strategy) => new(value, strategy); + public DeepMergeProp DeepMerge(object? value, string[]? strategies) => new(value, strategies); + public DeepMergeProp DeepMerge(Func callback, string strategy) => new(callback, strategy); + public DeepMergeProp DeepMerge(Func callback, string[]? strategies) => new(callback, strategies); + public DeepMergeProp DeepMerge(Func> callback, string strategy) => new(callback, strategy); + public DeepMergeProp DeepMerge(Func> callback, string[]? strategies) => new(callback, strategies); public OptionalProp Optional(Func callback) => new(callback); public OptionalProp Optional(Func> callback) => new(callback); } diff --git a/InertiaCoreTests/UnitTestDeepMergeData.cs b/InertiaCoreTests/UnitTestDeepMergeData.cs new file mode 100644 index 0000000..53faf5f --- /dev/null +++ b/InertiaCoreTests/UnitTestDeepMergeData.cs @@ -0,0 +1,311 @@ +using InertiaCore.Models; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the deep merge data is fetched properly.")] + public async Task TestDeepMergeData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(() => + { + return "Deep Merge"; + }) + }); + + 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" }, + { "testFunc", "Func" }, + { "testDeepMerge", "Deep Merge" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } + + [Test] + [Description("Test if the deep merge data is fetched properly with specified partial props.")] + public async Task TestDeepMergePartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(() => "Deep Merge") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testDeepMerge" }, + { "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 + { + { "testFunc", "Func" }, + { "testDeepMerge", "Deep Merge" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } + + [Test] + [Description("Test if the deep merge async data is fetched properly.")] + public async Task TestDeepMergeAsyncData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Deep Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(testFunction) + }); + + 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" }, + { "testFunc", "Func" }, + { "testDeepMerge", "Deep Merge Async" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } + + [Test] + [Description("Test if the deep merge data is fetched properly without specified partial props.")] + public async Task TestDeepMergePartialDataOmitted() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Deep Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc" }, + { "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 + { + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.DeepMergeProps, Is.EqualTo(null)); + } + + [Test] + [Description("Test if there are no deep merge props when none are specified.")] + public async Task TestNoDeepMergeProps() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + }); + + 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" }, + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(null)); + } + + [Test] + [Description("Test if deep merge props are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestDeepMergePropsWithPartialExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeepMerge1 = _factory.DeepMerge(() => "Deep Merge1"), + TestDeepMerge2 = _factory.DeepMerge(() => "Deep Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Except", "testDeepMerge1" }, + { "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 + { + { "test", "Test" }, + { "testDeepMerge2", "Deep Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // testDeepMerge1 should be excluded from deep merge props due to PARTIAL_EXCEPT header + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge2" })); + } + + [Test] + [Description("Test if only specified deep merge props are included when using PARTIAL_ONLY header.")] + public async Task TestDeepMergePropsWithPartialOnly() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeepMerge1 = _factory.DeepMerge(() => "Deep Merge1"), + TestDeepMerge2 = _factory.DeepMerge(() => "Deep Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testDeepMerge1,testNormal" }, + { "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 + { + { "testDeepMerge1", "Deep Merge1" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // Only testDeepMerge1 should be in deep merge props since testDeepMerge2 was not included in PARTIAL_ONLY + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge1" })); + } + + [Test] + [Description("Test if deep merge props work with strategies.")] + public async Task TestDeepMergeWithStrategies() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeepMerge1 = _factory.DeepMerge("Deep Merge1", "deep"), + TestDeepMerge2 = _factory.DeepMerge(() => "Deep Merge2", new[] { "shallow", "replace" }), + TestNormal = "Normal" + }); + + 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" }, + { "testDeepMerge1", "Deep Merge1" }, + { "testDeepMerge2", "Deep Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge1", "testDeepMerge2" })); + // Deep merge props should also appear in merge strategies since they inherit from Mergeable + Assert.That(page?.MergeStrategies, Is.EqualTo(new Dictionary + { + { "testDeepMerge1", new[] { "deep" } }, + { "testDeepMerge2", new[] { "shallow", "replace" } } + })); + } + + [Test] + [Description("Test if regular merge and deep merge props coexist properly.")] + public async Task TestMergeAndDeepMergeCoexistence() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge = _factory.Merge(() => "Regular Merge"), + TestDeepMerge = _factory.DeepMerge(() => "Deep Merge"), + TestNormal = "Normal" + }); + + 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" }, + { "testMerge", "Regular Merge" }, + { "testDeepMerge", "Deep Merge" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge", "testDeepMerge" })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } +} \ No newline at end of file From a82297ad93070af4930be12a53a9c704205bdf9b Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Mon, 22 Sep 2025 02:31:25 -0400 Subject: [PATCH 31/34] [2.x] Refactor mergeStrategies argument to matchOn() method https://github.com/inertiajs/inertia-laravel/pull/747 --- InertiaCore/Inertia.cs | 24 ------------- InertiaCore/Models/Page.cs | 2 +- InertiaCore/Props/DeepMergeProp.cs | 44 +---------------------- InertiaCore/Props/MergeProp.cs | 38 +------------------- InertiaCore/Response.cs | 16 ++++----- InertiaCore/ResponseFactory.cs | 24 ------------- InertiaCore/Utils/Mergeable.cs | 10 ++++-- InertiaCoreTests/UnitTestDeepMergeData.cs | 13 +++---- InertiaCoreTests/UnitTestMergeData.cs | 39 ++++++++++---------- 9 files changed, 46 insertions(+), 164 deletions(-) diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index 41c1a74..8f6a4c9 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -51,33 +51,9 @@ public static class Inertia public static MergeProp Merge(Func> callback) => _factory.Merge(callback); - public static MergeProp Merge(object? value, string strategy) => _factory.Merge(value, strategy); - - public static MergeProp Merge(object? value, string[]? strategies) => _factory.Merge(value, strategies); - - public static MergeProp Merge(Func callback, string strategy) => _factory.Merge(callback, strategy); - - public static MergeProp Merge(Func callback, string[]? strategies) => _factory.Merge(callback, strategies); - - public static MergeProp Merge(Func> callback, string strategy) => _factory.Merge(callback, strategy); - - public static MergeProp Merge(Func> callback, string[]? strategies) => _factory.Merge(callback, strategies); - public static DeepMergeProp DeepMerge(object? value) => _factory.DeepMerge(value); public static DeepMergeProp DeepMerge(Func callback) => _factory.DeepMerge(callback); public static DeepMergeProp DeepMerge(Func> callback) => _factory.DeepMerge(callback); - - public static DeepMergeProp DeepMerge(object? value, string strategy) => _factory.DeepMerge(value, strategy); - - public static DeepMergeProp DeepMerge(object? value, string[]? strategies) => _factory.DeepMerge(value, strategies); - - public static DeepMergeProp DeepMerge(Func callback, string strategy) => _factory.DeepMerge(callback, strategy); - - public static DeepMergeProp DeepMerge(Func callback, string[]? strategies) => _factory.DeepMerge(callback, strategies); - - public static DeepMergeProp DeepMerge(Func> callback, string strategy) => _factory.DeepMerge(callback, strategy); - - public static DeepMergeProp DeepMerge(Func> callback, string[]? strategies) => _factory.DeepMerge(callback, strategies); } diff --git a/InertiaCore/Models/Page.cs b/InertiaCore/Models/Page.cs index 0073e7f..df7fbc4 100644 --- a/InertiaCore/Models/Page.cs +++ b/InertiaCore/Models/Page.cs @@ -15,7 +15,7 @@ internal class Page public List? MergeProps { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public Dictionary? MergeStrategies { get; set; } + public Dictionary? MatchPropsOn { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? DeepMergeProps { get; set; } diff --git a/InertiaCore/Props/DeepMergeProp.cs b/InertiaCore/Props/DeepMergeProp.cs index 133db11..f2a03b2 100644 --- a/InertiaCore/Props/DeepMergeProp.cs +++ b/InertiaCore/Props/DeepMergeProp.cs @@ -5,7 +5,7 @@ namespace InertiaCore.Props; public class DeepMergeProp : InvokableProp, Mergeable { public bool merge { get; set; } = true; - public string[]? mergeStrategies { get; set; } + public string[]? matchOn { get; set; } public bool deepMerge { get; set; } = true; public DeepMergeProp(object? value) : base(value) @@ -14,59 +14,17 @@ public DeepMergeProp(object? value) : base(value) deepMerge = true; } - public DeepMergeProp(object? value, string[]? strategies) : base(value) - { - merge = true; - deepMerge = true; - mergeStrategies = strategies; - } - - public DeepMergeProp(object? value, string strategy) : base(value) - { - merge = true; - deepMerge = true; - mergeStrategies = new[] { strategy }; - } - internal DeepMergeProp(Func value) : base(value) { merge = true; deepMerge = true; } - internal DeepMergeProp(Func value, string[]? strategies) : base(value) - { - merge = true; - deepMerge = true; - mergeStrategies = strategies; - } - - internal DeepMergeProp(Func value, string strategy) : base(value) - { - merge = true; - deepMerge = true; - mergeStrategies = new[] { strategy }; - } - internal DeepMergeProp(Func> value) : base(value) { merge = true; deepMerge = true; } - internal DeepMergeProp(Func> value, string[]? strategies) : base(value) - { - merge = true; - deepMerge = true; - mergeStrategies = strategies; - } - - internal DeepMergeProp(Func> value, string strategy) : base(value) - { - merge = true; - deepMerge = true; - mergeStrategies = new[] { strategy }; - } - public bool ShouldDeepMerge() => deepMerge; } \ No newline at end of file diff --git a/InertiaCore/Props/MergeProp.cs b/InertiaCore/Props/MergeProp.cs index 69d943f..f0d55f8 100644 --- a/InertiaCore/Props/MergeProp.cs +++ b/InertiaCore/Props/MergeProp.cs @@ -5,58 +5,22 @@ namespace InertiaCore.Utils; public class MergeProp : InvokableProp, Mergeable { public bool merge { get; set; } = true; - public string[]? mergeStrategies { get; set; } + public string[]? matchOn { get; set; } public MergeProp(object? value) : base(value) { merge = true; } - public MergeProp(object? value, string[]? strategies) : base(value) - { - merge = true; - mergeStrategies = strategies; - } - - public MergeProp(object? value, string strategy) : base(value) - { - merge = true; - mergeStrategies = new[] { strategy }; - } - internal MergeProp(Func value) : base(value) { merge = true; } - internal MergeProp(Func value, string[]? strategies) : base(value) - { - merge = true; - mergeStrategies = strategies; - } - - internal MergeProp(Func value, string strategy) : base(value) - { - merge = true; - mergeStrategies = new[] { strategy }; - } - internal MergeProp(Func> value) : base(value) { merge = true; } - - internal MergeProp(Func> value, string[]? strategies) : base(value) - { - merge = true; - mergeStrategies = strategies; - } - - internal MergeProp(Func> value, string strategy) : base(value) - { - merge = true; - mergeStrategies = new[] { strategy }; - } } diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 128a300..1a08dd2 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -48,7 +48,7 @@ protected internal async Task ProcessResponse() }; page.MergeProps = ResolveMergeProps(props); - page.MergeStrategies = ResolveMergeStrategies(props); + page.MatchPropsOn = ResolveMatchPropsOn(props); page.DeepMergeProps = ResolveDeepMergeProps(props); page.Props["errors"] = GetErrors(); @@ -201,9 +201,9 @@ protected internal async Task ProcessResponse() } /// - /// Resolve merge strategies for properties that should be merged with custom strategies. + /// Resolve match props on for properties that should be matched on specific keys. /// - private Dictionary? ResolveMergeStrategies(Dictionary props) + private Dictionary? ResolveMatchPropsOn(Dictionary props) { // Parse the "RESET" header into a collection of keys to reset var resetProps = new HashSet( @@ -235,24 +235,24 @@ protected internal async Task ProcessResponse() .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase .ToList(); - // Filter the props that have merge strategies - var mergeStrategies = _props.Where(o => o.Value is Mergeable mergeable && mergeable.ShouldMerge() && mergeable.GetMergeStrategies() != null) + // Filter the props that have match on keys + var matchPropsOn = _props.Where(o => o.Value is Mergeable mergeable && mergeable.ShouldMerge() && mergeable.GetMatchOn() != null) .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified keys .Where(kv => resolvedProps.Contains(kv.Key.ToCamelCase())) // Filter only the props that are in the resolved props .ToDictionary( kv => kv.Key.ToCamelCase(), // Convert property name to camelCase - kv => ((Mergeable)kv.Value!).GetMergeStrategies()! + kv => ((Mergeable)kv.Value!).GetMatchOn()! ); - if (mergeStrategies.Count == 0) + if (matchPropsOn.Count == 0) { return null; } // Return the result - return mergeStrategies; + return matchPropsOn; } /// diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 7689a11..870e667 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -32,21 +32,9 @@ internal interface IResponseFactory public MergeProp Merge(object? value); public MergeProp Merge(Func callback); public MergeProp Merge(Func> callback); - public MergeProp Merge(object? value, string strategy); - public MergeProp Merge(object? value, string[]? strategies); - public MergeProp Merge(Func callback, string strategy); - public MergeProp Merge(Func callback, string[]? strategies); - public MergeProp Merge(Func> callback, string strategy); - public MergeProp Merge(Func> callback, string[]? strategies); public DeepMergeProp DeepMerge(object? value); public DeepMergeProp DeepMerge(Func callback); public DeepMergeProp DeepMerge(Func> callback); - public DeepMergeProp DeepMerge(object? value, string strategy); - public DeepMergeProp DeepMerge(object? value, string[]? strategies); - public DeepMergeProp DeepMerge(Func callback, string strategy); - public DeepMergeProp DeepMerge(Func callback, string[]? strategies); - public DeepMergeProp DeepMerge(Func> callback, string strategy); - public DeepMergeProp DeepMerge(Func> callback, string[]? strategies); public OptionalProp Optional(Func callback); public OptionalProp Optional(Func> callback); } @@ -167,21 +155,9 @@ public void Share(IDictionary data) public MergeProp Merge(object? value) => new(value); public MergeProp Merge(Func callback) => new(callback); public MergeProp Merge(Func> callback) => new(callback); - public MergeProp Merge(object? value, string strategy) => new(value, strategy); - public MergeProp Merge(object? value, string[]? strategies) => new(value, strategies); - public MergeProp Merge(Func callback, string strategy) => new(callback, strategy); - public MergeProp Merge(Func callback, string[]? strategies) => new(callback, strategies); - public MergeProp Merge(Func> callback, string strategy) => new(callback, strategy); - public MergeProp Merge(Func> callback, string[]? strategies) => new(callback, strategies); public DeepMergeProp DeepMerge(object? value) => new(value); public DeepMergeProp DeepMerge(Func callback) => new(callback); public DeepMergeProp DeepMerge(Func> callback) => new(callback); - public DeepMergeProp DeepMerge(object? value, string strategy) => new(value, strategy); - public DeepMergeProp DeepMerge(object? value, string[]? strategies) => new(value, strategies); - public DeepMergeProp DeepMerge(Func callback, string strategy) => new(callback, strategy); - public DeepMergeProp DeepMerge(Func callback, string[]? strategies) => new(callback, strategies); - public DeepMergeProp DeepMerge(Func> callback, string strategy) => new(callback, strategy); - public DeepMergeProp DeepMerge(Func> callback, string[]? strategies) => new(callback, strategies); public OptionalProp Optional(Func callback) => new(callback); public OptionalProp Optional(Func> callback) => new(callback); } diff --git a/InertiaCore/Utils/Mergeable.cs b/InertiaCore/Utils/Mergeable.cs index bcd3616..481b626 100644 --- a/InertiaCore/Utils/Mergeable.cs +++ b/InertiaCore/Utils/Mergeable.cs @@ -3,7 +3,7 @@ namespace InertiaCore.Utils; public interface Mergeable { public bool merge { get; set; } - public string[]? mergeStrategies { get; set; } + public string[]? matchOn { get; set; } public Mergeable Merge() { @@ -12,6 +12,12 @@ public Mergeable Merge() return this; } + public Mergeable MatchesOn(params string[] keys) + { + matchOn = keys; + return this; + } + public bool ShouldMerge() => merge; - public string[]? GetMergeStrategies() => mergeStrategies; + public string[]? GetMatchOn() => matchOn; } diff --git a/InertiaCoreTests/UnitTestDeepMergeData.cs b/InertiaCoreTests/UnitTestDeepMergeData.cs index 53faf5f..0ae581f 100644 --- a/InertiaCoreTests/UnitTestDeepMergeData.cs +++ b/InertiaCoreTests/UnitTestDeepMergeData.cs @@ -1,4 +1,5 @@ using InertiaCore.Models; +using InertiaCore.Utils; using Microsoft.AspNetCore.Http; namespace InertiaCoreTests; @@ -241,14 +242,14 @@ public async Task TestDeepMergePropsWithPartialOnly() } [Test] - [Description("Test if deep merge props work with strategies.")] - public async Task TestDeepMergeWithStrategies() + [Description("Test if deep merge props work with match on keys.")] + public async Task TestDeepMergeWithMatchOn() { var response = _factory.Render("Test/Page", new { Test = "Test", - TestDeepMerge1 = _factory.DeepMerge("Deep Merge1", "deep"), - TestDeepMerge2 = _factory.DeepMerge(() => "Deep Merge2", new[] { "shallow", "replace" }), + TestDeepMerge1 = ((Mergeable)_factory.DeepMerge("Deep Merge1")).MatchesOn("deep"), + TestDeepMerge2 = ((Mergeable)_factory.DeepMerge(() => "Deep Merge2")).MatchesOn("shallow", "replace"), TestNormal = "Normal" }); @@ -269,8 +270,8 @@ public async Task TestDeepMergeWithStrategies() })); Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge1", "testDeepMerge2" })); - // Deep merge props should also appear in merge strategies since they inherit from Mergeable - Assert.That(page?.MergeStrategies, Is.EqualTo(new Dictionary + // Deep merge props should also appear in match props on since they inherit from Mergeable + Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary { { "testDeepMerge1", new[] { "deep" } }, { "testDeepMerge2", new[] { "shallow", "replace" } } diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs index 606fa2e..2aec9cd 100644 --- a/InertiaCoreTests/UnitTestMergeData.cs +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -1,4 +1,5 @@ using InertiaCore.Models; +using InertiaCore.Utils; using Microsoft.AspNetCore.Http; namespace InertiaCoreTests; @@ -319,14 +320,14 @@ public async Task TestMergePropsWithPartialOnlyAndExcept() } [Test] - [Description("Test if merge strategies are resolved properly for merge props.")] - public async Task TestMergeStrategies() + [Description("Test if match props on are resolved properly for merge props.")] + public async Task TestMatchPropsOn() { var response = _factory.Render("Test/Page", new { Test = "Test", - TestMerge1 = _factory.Merge("Merge1", "deep"), - TestMerge2 = _factory.Merge(() => "Merge2", new[] { "shallow", "replace" }), + TestMerge1 = ((Mergeable)_factory.Merge("Merge1")).MatchesOn("deep"), + TestMerge2 = ((Mergeable)_factory.Merge(() => "Merge2")).MatchesOn("shallow", "replace"), TestNormal = "Normal" }); @@ -347,7 +348,7 @@ public async Task TestMergeStrategies() })); Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge2" })); - Assert.That(page?.MergeStrategies, Is.EqualTo(new Dictionary + Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary { { "testMerge1", new[] { "deep" } }, { "testMerge2", new[] { "shallow", "replace" } } @@ -355,14 +356,14 @@ public async Task TestMergeStrategies() } [Test] - [Description("Test if merge strategies are handled properly with partial props.")] - public async Task TestMergeStrategiesWithPartialProps() + [Description("Test if match props on are handled properly with partial props.")] + public async Task TestMatchPropsOnWithPartialProps() { var response = _factory.Render("Test/Page", new { - TestMerge1 = _factory.Merge("Merge1", "deep"), - TestMerge2 = _factory.Merge(() => "Merge2", new[] { "shallow", "replace" }), - TestMerge3 = _factory.Merge("Merge3", "custom") + TestMerge1 = ((Mergeable)_factory.Merge("Merge1")).MatchesOn("deep"), + TestMerge2 = ((Mergeable)_factory.Merge(() => "Merge2")).MatchesOn("shallow", "replace"), + TestMerge3 = ((Mergeable)_factory.Merge("Merge3")).MatchesOn("custom") }); var headers = new HeaderDictionary @@ -386,7 +387,7 @@ public async Task TestMergeStrategiesWithPartialProps() })); Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge3" })); - Assert.That(page?.MergeStrategies, Is.EqualTo(new Dictionary + Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary { { "testMerge1", new[] { "deep" } }, { "testMerge3", new[] { "custom" } } @@ -394,14 +395,14 @@ public async Task TestMergeStrategiesWithPartialProps() } [Test] - [Description("Test if merge strategies are excluded when using PARTIAL_EXCEPT header.")] - public async Task TestMergeStrategiesWithPartialExcept() + [Description("Test if match props on are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestMatchPropsOnWithPartialExcept() { var response = _factory.Render("Test/Page", new { Test = "Test", - TestMerge1 = _factory.Merge("Merge1", "deep"), - TestMerge2 = _factory.Merge(() => "Merge2", new[] { "shallow", "replace" }), + TestMerge1 = ((Mergeable)_factory.Merge("Merge1")).MatchesOn("deep"), + TestMerge2 = ((Mergeable)_factory.Merge(() => "Merge2")).MatchesOn("shallow", "replace"), TestNormal = "Normal" }); @@ -427,15 +428,15 @@ public async Task TestMergeStrategiesWithPartialExcept() })); Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); - Assert.That(page?.MergeStrategies, Is.EqualTo(new Dictionary + Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary { { "testMerge2", new[] { "shallow", "replace" } } })); } [Test] - [Description("Test if merge strategies are null when no merge props have strategies.")] - public async Task TestNoMergeStrategies() + [Description("Test if match props on are null when no merge props have match keys.")] + public async Task TestNoMatchPropsOn() { var response = _factory.Render("Test/Page", new { @@ -460,7 +461,7 @@ public async Task TestNoMergeStrategies() })); Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); - Assert.That(page?.MergeStrategies, Is.EqualTo(null)); + Assert.That(page?.MatchPropsOn, Is.EqualTo(null)); } } From 6ab08f53deb6882ed532b6895717daeff657a2da Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Mon, 22 Sep 2025 02:39:00 -0400 Subject: [PATCH 32/34] deep merge on mergable interface --- InertiaCore/Utils/Mergeable.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/InertiaCore/Utils/Mergeable.cs b/InertiaCore/Utils/Mergeable.cs index 481b626..283241f 100644 --- a/InertiaCore/Utils/Mergeable.cs +++ b/InertiaCore/Utils/Mergeable.cs @@ -3,6 +3,7 @@ namespace InertiaCore.Utils; public interface Mergeable { public bool merge { get; set; } + public bool deepMerge { get; set; } public string[]? matchOn { get; set; } public Mergeable Merge() @@ -12,6 +13,13 @@ public Mergeable Merge() return this; } + public Mergeable DeepMerge() + { + deepMerge = true; + + return this->Merge(); + } + public Mergeable MatchesOn(params string[] keys) { matchOn = keys; @@ -19,5 +27,6 @@ public Mergeable MatchesOn(params string[] keys) } public bool ShouldMerge() => merge; + public bool ShouldDeepMerge() => deepMerge; public string[]? GetMatchOn() => matchOn; } From e1899d5c596996341056f42fb803a21b1227a806 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Mon, 22 Sep 2025 02:39:50 -0400 Subject: [PATCH 33/34] fix merge prop --- InertiaCore/Props/MergeProp.cs | 1 + InertiaCore/Utils/Mergeable.cs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/InertiaCore/Props/MergeProp.cs b/InertiaCore/Props/MergeProp.cs index f0d55f8..b957d34 100644 --- a/InertiaCore/Props/MergeProp.cs +++ b/InertiaCore/Props/MergeProp.cs @@ -5,6 +5,7 @@ namespace InertiaCore.Utils; public class MergeProp : InvokableProp, Mergeable { public bool merge { get; set; } = true; + public bool deepMerge { get; set; } = false; public string[]? matchOn { get; set; } public MergeProp(object? value) : base(value) diff --git a/InertiaCore/Utils/Mergeable.cs b/InertiaCore/Utils/Mergeable.cs index 283241f..8b982bf 100644 --- a/InertiaCore/Utils/Mergeable.cs +++ b/InertiaCore/Utils/Mergeable.cs @@ -17,7 +17,9 @@ public Mergeable DeepMerge() { deepMerge = true; - return this->Merge(); + merge = true; + + return this; } public Mergeable MatchesOn(params string[] keys) From b0c795a440e39f3d23abd0744bb2a0118841481f Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Sun, 12 Apr 2026 00:12:38 -0400 Subject: [PATCH 34/34] Refactor merge resolution to match Laravel adapter Mirror the canonical Inertia Laravel adapter's merge prop resolution so the wire format and filtering semantics stay in sync with the Inertia.js frontend contract. - Extend Mergeable with Append/AppendsAtPaths/PrependsAtPaths plus AppendAt/PrependAt/Prepend helpers and AppendsAtRoot/PrependsAtRoot predicates for directional merging at nested paths. - Add PrependProps to the Page model and change MatchPropsOn from Dictionary to a flat List of "key.strategy" entries, matching Laravel's resolveMergeMatchingKeys output. - Add GetMergeablePropsForRequest in Response that filters _props by Mergeable + ShouldMerge, then rejects Reset keys and applies Partial-Only / Partial-Except in one pass. All four Resolve methods operate on that pre-filtered collection, removing the duplicated header parsing that previously lived in each method. - Cover the Reset-header interaction with a new test that asserts a prop listed in X-Inertia-Reset is excluded from mergeProps. --- InertiaCore/Models/Page.cs | 5 +- InertiaCore/Props/DeepMergeProp.cs | 3 + InertiaCore/Props/MergeProp.cs | 4 + InertiaCore/Response.cs | 261 +++++++++++----------- InertiaCore/Utils/Mergeable.cs | 54 +++++ InertiaCoreTests/UnitTestDeepMergeData.cs | 9 +- InertiaCoreTests/UnitTestMergeData.cs | 45 +++- 7 files changed, 236 insertions(+), 145 deletions(-) diff --git a/InertiaCore/Models/Page.cs b/InertiaCore/Models/Page.cs index df7fbc4..1002e57 100644 --- a/InertiaCore/Models/Page.cs +++ b/InertiaCore/Models/Page.cs @@ -15,7 +15,10 @@ internal class Page public List? MergeProps { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public Dictionary? MatchPropsOn { get; set; } + public List? MatchPropsOn { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? PrependProps { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? DeepMergeProps { get; set; } diff --git a/InertiaCore/Props/DeepMergeProp.cs b/InertiaCore/Props/DeepMergeProp.cs index f2a03b2..e91141b 100644 --- a/InertiaCore/Props/DeepMergeProp.cs +++ b/InertiaCore/Props/DeepMergeProp.cs @@ -7,6 +7,9 @@ public class DeepMergeProp : InvokableProp, Mergeable public bool merge { get; set; } = true; public string[]? matchOn { get; set; } public bool deepMerge { get; set; } = true; + public bool Append { get; set; } = true; + public List AppendsAtPaths { get; } = new(); + public List PrependsAtPaths { get; } = new(); public DeepMergeProp(object? value) : base(value) { diff --git a/InertiaCore/Props/MergeProp.cs b/InertiaCore/Props/MergeProp.cs index b957d34..4210cc6 100644 --- a/InertiaCore/Props/MergeProp.cs +++ b/InertiaCore/Props/MergeProp.cs @@ -8,6 +8,10 @@ public class MergeProp : InvokableProp, Mergeable public bool deepMerge { get; set; } = false; public string[]? matchOn { get; set; } + public bool Append { get; set; } = true; + public List AppendsAtPaths { get; } = new(); + public List PrependsAtPaths { get; } = new(); + public MergeProp(object? value) : base(value) { merge = true; diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 1a08dd2..549d021 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -47,9 +47,11 @@ protected internal async Task ProcessResponse() ClearHistory = _clearHistory, }; - page.MergeProps = ResolveMergeProps(props); - page.MatchPropsOn = ResolveMatchPropsOn(props); - page.DeepMergeProps = ResolveDeepMergeProps(props); + var mergeable = GetMergeablePropsForRequest(); + page.MergeProps = ResolveMergeProps(mergeable); + page.PrependProps = ResolvePrependProps(mergeable); + page.DeepMergeProps = ResolveDeepMergeProps(mergeable); + page.MatchPropsOn = ResolveMatchPropsOn(mergeable); page.Props["errors"] = GetErrors(); SetPage(page); @@ -148,164 +150,159 @@ protected internal async Task ProcessResponse() } /// - /// Resolve `merge` properties that should be appended to the existing values by the front-end. + /// Get the props eligible for merging on this request. + /// Mirrors Laravel's Response::getMergePropsForRequest(): + /// filters _props to only Mergeable && ShouldMerge(), removes any keys + /// listed in the X-Inertia-Reset header, then applies Partial-Only / + /// Partial-Except filtering. /// - private List? ResolveMergeProps(Dictionary props) + private Dictionary GetMergeablePropsForRequest(bool rejectResetProps = true) { - // Parse the "RESET" header into a collection of keys to reset - var resetProps = new HashSet( - _context!.HttpContext.Request.Headers[InertiaHeader.Reset] - .ToString() - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()), - StringComparer.OrdinalIgnoreCase - ); - - // Parse the "PARTIAL_ONLY" header into a collection of keys to include - var onlyProps = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] - .ToString() - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()) - .Where(s => !string.IsNullOrEmpty(s)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - // Parse the "PARTIAL_EXCEPT" header into a collection of keys to exclude - var exceptProps = new HashSet( - _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] - .ToString() - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()), - StringComparer.OrdinalIgnoreCase - ); - - var resolvedProps = props - .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase - .ToList(); + var headers = _context!.HttpContext.Request.Headers; - // Filter the props that are Mergeable and should be merged - var mergeProps = _props.Where(o => o.Value is Mergeable mergeable && mergeable.ShouldMerge()) // Check if value is Mergeable and should merge - .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys - .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any - .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified keys - .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase - .Where(resolvedProps.Contains) // Filter only the props that are in the resolved props - .ToList(); + var resetKeys = rejectResetProps + ? ParseHeaderList(headers[InertiaHeader.Reset].ToString()) + : new HashSet(StringComparer.OrdinalIgnoreCase); + + var hasPartialOnly = headers.ContainsKey(InertiaHeader.PartialOnly); + var onlyKeys = hasPartialOnly + ? ParseHeaderList(headers[InertiaHeader.PartialOnly].ToString()) + : new HashSet(StringComparer.OrdinalIgnoreCase); - if (mergeProps.Count == 0) + var exceptKeys = ParseHeaderList(headers[InertiaHeader.PartialExcept].ToString()); + + var result = new Dictionary(); + foreach (var kv in _props) { - return null; + if (kv.Value is not Mergeable m || !m.ShouldMerge()) continue; + + var camel = kv.Key.ToCamelCase(); + + if (resetKeys.Contains(camel)) continue; + if (hasPartialOnly && !onlyKeys.Contains(camel)) continue; + if (exceptKeys.Contains(camel)) continue; + + result[kv.Key] = kv.Value; } - // Return the result - return mergeProps; + return result; + } + + private static HashSet ParseHeaderList(string? value) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(value)) return set; + + foreach (var part in value!.Split(',')) + { + var trimmed = part.Trim(); + if (trimmed.Length > 0) set.Add(trimmed); + } + return set; } /// - /// Resolve match props on for properties that should be matched on specific keys. + /// Resolve merge props that should be appended (excludes deep merge and prepend props). + /// Returns a flat list of prop keys or key.path entries. /// - private Dictionary? ResolveMatchPropsOn(Dictionary props) + private static List? ResolveMergeProps(Dictionary mergeProps) { - // Parse the "RESET" header into a collection of keys to reset - var resetProps = new HashSet( - _context!.HttpContext.Request.Headers[InertiaHeader.Reset] - .ToString() - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()), - StringComparer.OrdinalIgnoreCase - ); - - // Parse the "PARTIAL_ONLY" header into a collection of keys to include - var onlyProps = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] - .ToString() - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()) - .Where(s => !string.IsNullOrEmpty(s)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - // Parse the "PARTIAL_EXCEPT" header into a collection of keys to exclude - var exceptProps = new HashSet( - _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] - .ToString() - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()), - StringComparer.OrdinalIgnoreCase - ); - - var resolvedProps = props - .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase + var mergeableProps = mergeProps + .Where(kv => kv.Value is Mergeable m && !m.ShouldDeepMerge()) .ToList(); - // Filter the props that have match on keys - var matchPropsOn = _props.Where(o => o.Value is Mergeable mergeable && mergeable.ShouldMerge() && mergeable.GetMatchOn() != null) - .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys - .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any - .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified keys - .Where(kv => resolvedProps.Contains(kv.Key.ToCamelCase())) // Filter only the props that are in the resolved props - .ToDictionary( - kv => kv.Key.ToCamelCase(), // Convert property name to camelCase - kv => ((Mergeable)kv.Value!).GetMatchOn()! - ); - - if (matchPropsOn.Count == 0) + if (mergeableProps.Count == 0) return null; + + var result = new List(); + + foreach (var kv in mergeableProps) { - return null; + var m = (Mergeable)kv.Value!; + var key = kv.Key.ToCamelCase(); + + if (m.AppendsAtRoot()) + { + result.Add(key); + } + + foreach (var path in m.AppendsAtPaths) + { + result.Add($"{key}.{path}"); + } } - // Return the result - return matchPropsOn; + return result.Count > 0 ? result : null; } /// - /// Resolve deep merge properties that should be deeply merged with existing values by the front-end. + /// Resolve props that should be prepended during merging. + /// Returns a flat list of prop keys or key.path entries. /// - private List? ResolveDeepMergeProps(Dictionary props) + private static List? ResolvePrependProps(Dictionary mergeProps) { - // Parse the "RESET" header into a collection of keys to reset - var resetProps = new HashSet( - _context!.HttpContext.Request.Headers[InertiaHeader.Reset] - .ToString() - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()), - StringComparer.OrdinalIgnoreCase - ); - - // Parse the "PARTIAL_ONLY" header into a collection of keys to include - var onlyProps = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] - .ToString() - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()) - .Where(s => !string.IsNullOrEmpty(s)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - // Parse the "PARTIAL_EXCEPT" header into a collection of keys to exclude - var exceptProps = new HashSet( - _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] - .ToString() - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Trim()), - StringComparer.OrdinalIgnoreCase - ); - - var resolvedProps = props - .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase + var mergeableProps = mergeProps + .Where(kv => kv.Value is Mergeable m && !m.ShouldDeepMerge()) .ToList(); - // Filter the props that are DeepMergeable and should be deeply merged - var deepMergeProps = _props.Where(o => o.Value is DeepMergeProp deepMergeable && deepMergeable.ShouldDeepMerge()) // Check if value is DeepMergeProp and should deep merge - .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys - .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any - .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified keys - .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase - .Where(resolvedProps.Contains) // Filter only the props that are in the resolved props + if (mergeableProps.Count == 0) return null; + + var result = new List(); + + foreach (var kv in mergeableProps) + { + var m = (Mergeable)kv.Value!; + var key = kv.Key.ToCamelCase(); + + if (m.PrependsAtRoot()) + { + result.Add(key); + } + + foreach (var path in m.PrependsAtPaths) + { + result.Add($"{key}.{path}"); + } + } + + return result.Count > 0 ? result : null; + } + + /// + /// Resolve props that should be deep merged. + /// + private static List? ResolveDeepMergeProps(Dictionary mergeProps) + { + var deepMergeProps = mergeProps + .Where(kv => kv.Value is Mergeable m && m.ShouldDeepMerge()) + .Select(kv => kv.Key.ToCamelCase()) .ToList(); - if (deepMergeProps.Count == 0) + return deepMergeProps.Count > 0 ? deepMergeProps : null; + } + + /// + /// Resolve the match-on keys for merge props as a flat list. + /// Returns entries like "propKey.strategy" matching Laravel's format. + /// + private static List? ResolveMatchPropsOn(Dictionary mergeProps) + { + var result = new List(); + + foreach (var kv in mergeProps) { - return null; + if (kv.Value is not Mergeable m) continue; + + var matchOnKeys = m.GetMatchOn(); + if (matchOnKeys == null || matchOnKeys.Length == 0) continue; + + var key = kv.Key.ToCamelCase(); + foreach (var matchOnItem in matchOnKeys) + { + result.Add($"{key}.{matchOnItem}"); + } } - // Return the result - return deepMergeProps; + return result.Count > 0 ? result : null; } /// diff --git a/InertiaCore/Utils/Mergeable.cs b/InertiaCore/Utils/Mergeable.cs index 8b982bf..0c4af81 100644 --- a/InertiaCore/Utils/Mergeable.cs +++ b/InertiaCore/Utils/Mergeable.cs @@ -31,4 +31,58 @@ public Mergeable MatchesOn(params string[] keys) public bool ShouldMerge() => merge; public bool ShouldDeepMerge() => deepMerge; public string[]? GetMatchOn() => matchOn; + + bool Append { get; set; } + List AppendsAtPaths { get; } + List PrependsAtPaths { get; } + + /// + /// Specify that the value should be appended. + /// + public Mergeable AppendAt(string path, string? matchOnKey = null) + { + AppendsAtPaths.Add(path); + if (matchOnKey != null) + { + var existing = matchOn?.ToList() ?? new List(); + existing.Add($"{path}.{matchOnKey}"); + matchOn = existing.ToArray(); + } + return this; + } + + /// + /// Specify that the value should be prepended. + /// + public Mergeable PrependAt(string path, string? matchOnKey = null) + { + PrependsAtPaths.Add(path); + if (matchOnKey != null) + { + var existing = matchOn?.ToList() ?? new List(); + existing.Add($"{path}.{matchOnKey}"); + matchOn = existing.ToArray(); + } + return this; + } + + /// + /// Set the default merge direction to prepend. + /// + public Mergeable Prepend() + { + Append = false; + merge = true; + return this; + } + + /// + /// Whether this property appends at root level (no nested paths defined, default direction is append). + /// + public bool AppendsAtRoot() => Append && AppendsAtPaths.Count == 0 && PrependsAtPaths.Count == 0; + + /// + /// Whether this property prepends at root level. + /// + public bool PrependsAtRoot() => !Append && AppendsAtPaths.Count == 0 && PrependsAtPaths.Count == 0; } diff --git a/InertiaCoreTests/UnitTestDeepMergeData.cs b/InertiaCoreTests/UnitTestDeepMergeData.cs index 0ae581f..de0e395 100644 --- a/InertiaCoreTests/UnitTestDeepMergeData.cs +++ b/InertiaCoreTests/UnitTestDeepMergeData.cs @@ -271,10 +271,11 @@ public async Task TestDeepMergeWithMatchOn() Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge1", "testDeepMerge2" })); // Deep merge props should also appear in match props on since they inherit from Mergeable - Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary + Assert.That(page?.MatchPropsOn, Is.EqualTo(new List { - { "testDeepMerge1", new[] { "deep" } }, - { "testDeepMerge2", new[] { "shallow", "replace" } } + "testDeepMerge1.deep", + "testDeepMerge2.shallow", + "testDeepMerge2.replace" })); } @@ -306,7 +307,7 @@ public async Task TestMergeAndDeepMergeCoexistence() { "errors", new Dictionary(0) } })); - Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge", "testDeepMerge" })); + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); } } \ No newline at end of file diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs index 2aec9cd..2ec10f6 100644 --- a/InertiaCoreTests/UnitTestMergeData.cs +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -348,10 +348,11 @@ public async Task TestMatchPropsOn() })); Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge2" })); - Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary + Assert.That(page?.MatchPropsOn, Is.EqualTo(new List { - { "testMerge1", new[] { "deep" } }, - { "testMerge2", new[] { "shallow", "replace" } } + "testMerge1.deep", + "testMerge2.shallow", + "testMerge2.replace" })); } @@ -387,10 +388,10 @@ public async Task TestMatchPropsOnWithPartialProps() })); Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge3" })); - Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary + Assert.That(page?.MatchPropsOn, Is.EqualTo(new List { - { "testMerge1", new[] { "deep" } }, - { "testMerge3", new[] { "custom" } } + "testMerge1.deep", + "testMerge3.custom" })); } @@ -428,9 +429,10 @@ public async Task TestMatchPropsOnWithPartialExcept() })); Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); - Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary + Assert.That(page?.MatchPropsOn, Is.EqualTo(new List { - { "testMerge2", new[] { "shallow", "replace" } } + "testMerge2.shallow", + "testMerge2.replace" })); } @@ -464,4 +466,31 @@ public async Task TestNoMatchPropsOn() Assert.That(page?.MatchPropsOn, Is.EqualTo(null)); } + [Test] + [Description("Test if merge props honor the X-Inertia-Reset header.")] + public async Task TestMergePropsWithResetHeader() + { + var response = _factory.Render("Test/Page", new + { + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Reset", "testMerge1" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + // testMerge1 should NOT appear in mergeProps because it's in the reset list + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); + } + }