-
Notifications
You must be signed in to change notification settings - Fork 3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support for Razor Components (aka server side Blazor) #28
Comments
UPDATE: There is now a Blazor integration page in the Simple Injector documentation. I tried to get the full stack working with VS2019prev, .NET Core v3, templates, etc, but for some reason the Can you provide me with a .zip file of your solution? And don't forget to post the full stack trace. |
You used to have to install the Blazor extension to get the dotnet templates working. Now, as far as I can tell, it's major benefit is giving you intellisense in When I run I threw it on github here. Stack trace:
I had to navigate to https://localhost:5001/fetchdata directly from a new tab to see the error in the |
The stack trace gives some clues of what it going on. From that, we can take a peek in the actual source code. A painful observation is that the building of these Razor Components is tightly coupled to the built-in configuration system, and I see no way to redirect the resolution of those components to a non-conforming container, such as Simple Injector. Hopefully, this code isn't released yet and the Microsoft team can still make some changes to the design. Hopefully @davidfowl can chime in and correct me if I'm wrong. David, am I correct by saying the necessary Seam is missing in this part of the ASP.NET Core code base or is there a different way for non-conformers to plugin at this point? If there's no way to integrate, can you make sure this is something that will be addressed? |
Let's see what happens. |
ASP.NET Core 5.0 will allow the creation of Razor Components to be intercepted. Here's how to integrate it with Simple Injector. Also interesting |
After trying out the linked snippet in a NET 5 Blazor Server project, it looks like From looking at the latest version of private class SimpleInjectorComponentActivator : IComponentActivator
{
private readonly Container _container;
public SimpleInjectorComponentActivator(Container container)
{
_container = container ?? throw new ArgumentNullException(nameof(container));
}
public IComponent CreateInstance(Type componentType)
{
try
{
return (IComponent)_container.GetInstance(componentType);
}
catch (ActivationException e)
{
// not a fan... this still executes if a legitimate error occurred when resolving the component above
var instance = Activator.CreateInstance(componentType);
if (!(instance is IComponent component))
throw new ArgumentException($"The type {componentType.FullName} does not implement {nameof(IComponent)}.", nameof(componentType));
return component;
}
}
} The above gets me part-way there. I seem to be missing a piece when it comes to resolving my own Blazor components that depend on a Blazor-provided component registered with scoped lifestyle ( It is also possible that I am missing something that renders all of the above moot. Please advise if that is indeed the case. |
I had forgotten that Simple Injector can resolve unregistered types via the private class SimpleInjectorComponentActivator : IComponentActivator
{
private readonly Container _container;
public SimpleInjectorComponentActivator(Container container)
{
_container = container ?? throw new ArgumentNullException(nameof(container));
_container.ResolveUnregisteredType += (s, e) =>
{
if (!e.Handled && e.UnregisteredServiceType.IsAssignableTo(typeof(IComponent)))
{
var registration = Lifestyle.Transient.CreateRegistration(
e.UnregisteredServiceType,
() => Activator.CreateInstance(e.UnregisteredServiceType),
_container);
e.Register(registration);
}
};
}
public IComponent CreateInstance(Type componentType) => (IComponent)_container.GetInstance(componentType);
} |
The scoping issue appears to have been caused by me registering some scoped-lifetime Blazor services with the Microsoft DI container and with Simple Injector. Obviously, that's wrong. Interestingly, the application starts successfully if the scoped-lifetime Blazor services are only registered with the Microsoft DI container, but not if they are only registered with the Simple Injector container. The following exception is thrown in that case.
( I'll keep the scoped services registered in the Microsoft container. The following configuration needs to happen too, which I found here and here. services.AddSimpleInjector(_container, options =>
{
options.AddAspNetCore(ServiceScopeReuseBehavior.OnePerNestedScope);
}); It looks like everything is working after making the above two changes (resolving unregistered component types using |
One last thing... Blazor handles disposal of components that implement private static readonly Assembly[] _blazorComponentAssemblyCollection = { ... };
// to register all Blazor components
foreach (var type in container.GetTypesToRegister<IComponent>(_blazorComponentAssemblyCollection))
{
container.Register(type);
container.GetRegistration(type).Registration
.SuppressDiagnosticWarning(
DiagnosticType.DisposableTransientComponent,
"Disposal handled by Blazor.");
} |
You are right. I missed that. I should update the example to reflect this. Your solution using Here is an alternative solution with a similar effect: This code snippet has been updated public sealed class SimpleInjectorComponentActivator : IComponentActivator
{
private readonly Dictionary<Type, InstanceProducer<IComponent>> applicationProducers;
public SimpleInjectorComponentActivator(Container container, Assembly[] assemblies)
{
this.applicationProducers = (
from type in container.GetTypesToRegister<IComponent>(assemblies)
select (type, producer: this.CreateBlazorProducer(type, container)))
.ToDictionary(v => v.type, v => v.producer);
}
public IComponent CreateInstance(Type type) =>
this.applicationProducers.TryGetValue(type, out var producer)
? producer.GetInstance()
: (IComponent)Activator.CreateInstance(type);
private InstanceProducer<IComponent> CreateBlazorProducer(Type type, Container container)
{
var producer = Lifestyle.Transient.CreateProducer<IComponent>(type, container);
producer.Registration.SuppressDiagnosticWarning(
DiagnosticType.DisposableTransientComponent,
"Blazor will dispose components.");
return producer;
}
}
This code snippet shows how to register this class: services.AddSingleton<IComponentActivator>(
new SimpleInjectorComponentActivator(container, new[] { typeof(Startup).Assembly }); |
That is correct. I started off the original integration code and started tweaking it from there. I like your approach of bundling registration and resolution of Blazor components together. Thanks for writing that up! Unfortunately, |
I am still receiving errors related to
public abstract class PageComponentBase : ComponentBase
{
protected PageComponentBase(NavigationManager navigationManager)
{
NavigationManager = navigationManager ?? throw new ArgumentNullException(nameof(navigationManager));
}
protected NavigationManager NavigationManager { get; }
public abstract string Title { get; }
} The exception occurs when I click a Cancel button in that component that is supposed to use the // in SignupUser component
private void Cancel() => NavigationManager.NavigateTo("", true); As previously mentioned, |
Changing the public abstract class PageComponentBase : ComponentBase
{
// back to property injection
[Inject] protected NavigationManager NavigationManager { get; set; }
public abstract string Title { get; }
} This works because Blazor will still inject services for components instantiated by a custom component activator. |
Your But this is probably a sign of a bigger issue with scoping. This could easily pop-up in other places as well. I've quickly been going through the MSDN docs you provider, but I'm starting to realize that Blazor works quite differently from your typical server application. I have to investigate further, because I'm a bit in the dark right now. |
I've just started using Blazor and I'm a bit confused. Using the default "Blazor Server App" (with .NET 5.0) template, after adding all the default SI integrations and the changes suggested in this issue (including the I moved the
Does this mean that that path is still inaccessible to Simple Injector? I've created this gist with my changes. |
I finally have some spare time to dive a bit deeper into Blazor. I've been going through its documentation, and trying to add integration with Simple Injector using the default template. I'm however starting to feel that we have a serious problem here. The problem lies in how Microsoft's internal As I see it, it shouldn't have been the
|
One workaround around this is to refrain from using the @page "/fetchdata"
@using BlazorApp1.Data
<h1>Weather forecast</h1>
<table class="table">
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.Summary</td>
</tr>
}
</table>
}
@code {
// Dependency Property with custom attribute here.
[Dependency]
public WeatherForecastService ForecastService { get; set; }
private WeatherForecast[] forecasts;
protected override async Task OnInitializedAsync()
{
forecasts = await ForecastService.GetForecastAsync(DateTime.Now);
}
} This can be wired up as follows: namespace BlazorApp1
{
// Your custom attribute
[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
public sealed class DependencyAttribute : Attribute { }
// custom property selection behavior that allows Simple Injector to inject properties
// marked with [Dependency]
class DependencyAttributePropertySelectionBehavior : IPropertySelectionBehavior
{
public bool SelectProperty(Type type, PropertyInfo prop) =>
prop.GetCustomAttributes(typeof(DependencyAttribute)).Any();
}
public class Startup
{
private Container container = new Container();
public Startup(IConfiguration configuration)
{
this.Configuration = configuration;
// Instruct Simple Injector to use the custom property selection behavior
container.Options.PropertySelectionBehavior = new DependencyAttributePropertySelectionBehavior();
}
[...]
}
} The reason you should define your own injection attribute is because |
@RyanMarcotte, I have been able to reproduce your |
@RyanMarcotte, good news. I think I got to the heart of the issue concerning scoping, and now have a better understanding of how scoping works in Blazor and I think I have a solution that allows Simple Injector to be fully integrated in Blazor. It does mean I have to add a small feature to the core library. After I released a beta for the core library, I'll share the integration code that you can use to try. But long story short, Blazor scopes are long lived, while the asynchronous context (that Simple Injector's
Stay tuned. |
@RyanMarcotte and others, Below is a prototype that allows integrating Simple Injector with Blazor. It requires the following NuGet packages:
This prototype solves the problems with scoping as described by @RyanMarcotte. I'm really interested in feedback by anyone who can try this out. When it seems to work correctly, I will likely transform this into a integration package. This prototype does not include property injection, but this can be added using the information in this earlier comment. LAST UPDATE: 2021-02-19 using System;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SimpleInjector;
using SimpleInjector.Advanced;
using SimpleInjector.Diagnostics;
using SimpleInjector.Integration.ServiceCollection;
using SimpleInjector.Lifestyles;
public class Startup
{
private readonly Container container = new Container();
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddServerSideBlazor();
services.AddSimpleInjector(container, options =>
{
// Custom extension method; see code below.
options.AddServerSideBlazor(this.GetType().Assembly);
// Adds the IServiceScopeFactory, required for the IServiceScope registration.
container.Register(
() => options.ApplicationServices.GetRequiredService<IServiceScopeFactory>(),
Lifestyle.Singleton);
});
// Replace the IServiceScope registration made by .AddSimpleInjector
// (must be called after AddSimpleInjector)
container.Options.AllowOverridingRegistrations = true;
container.Register<ServiceScopeAccessor>(Lifestyle.Scoped);
this.container.Register<IServiceScope>(
() => container.GetInstance<ServiceScopeAccessor>().Scope
?? container.GetInstance<IServiceScopeFactory>().CreateScope(),
Lifestyle.Scoped);
container.Options.AllowOverridingRegistrations = false;
InitializeContainer();
}
private void InitializeContainer()
{
// container.RegisterSingleton<WeatherForecastService>();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.ApplicationServices.UseSimpleInjector(container);
// Default VS template stuff
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
}
app.UseStaticFiles();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapBlazorHub();
endpoints.MapFallbackToPage("/_Host");
});
container.Verify();
}
}
public static class BlazorExtensions
{
private static readonly AsyncScopedLifestyle lifestyle = new AsyncScopedLifestyle();
public static void AddServerSideBlazor(
this SimpleInjectorAddOptions options, params Assembly[] assemblies)
{
options.Container.Options.DefaultScopedLifestyle = new AsyncScopedLifestyle();
options.Services.AddScoped<ScopeAccessor>();
options.Services.AddScoped<IComponentActivator, SimpleInjectorComponentActivator>();
// HACK: This internal ComponentHub type needs to be added for the
// SimpleInjectorBlazorHubActivator to work.
options.Services.AddTransient(
typeof(Microsoft.AspNetCore.Components.Server.CircuitOptions).Assembly.GetTypes().First(
t => t.FullName == "Microsoft.AspNetCore.Components.Server.ComponentHub"));
options.Services.AddScoped(typeof(IHubActivator<>), typeof(SimpleInjectorBlazorHubActivator<>));
RegisterBlazorComponents(options, assemblies);
}
public static void ApplyServiceScope(this Container container, IServiceProvider requestServices)
{
var accessor = requestServices.GetRequiredService<ScopeAccessor>();
if (accessor.Scope is null)
{
accessor.Scope = AsyncScopedLifestyle.BeginScope(container);
accessor.Scope.GetInstance<ServiceScopeAccessor>().Scope = (IServiceScope)requestServices;
}
else
{
lifestyle.SetCurrentScope(accessor.Scope);
}
}
private static void RegisterBlazorComponents(SimpleInjectorAddOptions options, Assembly[] assemblies)
{
var types = options.Container.GetTypesToRegister(typeof(IComponent), assemblies,
new TypesToRegisterOptions { IncludeGenericTypeDefinitions = true });
foreach (Type type in types.Where(t => !t.IsGenericTypeDefinition))
{
var registration = Lifestyle.Transient.CreateRegistration(type, options.Container);
registration.SuppressDiagnosticWarning(
DiagnosticType.DisposableTransientComponent,
"Blazor will dispose components.");
options.Container.AddRegistration(type, registration);
}
foreach (Type type in types.Where(t => t.IsGenericTypeDefinition))
{
options.Container.Register(type, type, Lifestyle.Transient);
}
}
}
public sealed class ScopeAccessor : IAsyncDisposable
{
public Scope Scope { get; set; }
public ValueTask DisposeAsync() => this.Scope.DisposeAsync();
}
public sealed class ServiceScopeAccessor
{
public IServiceScope Scope { get; set; }
}
public sealed class SimpleInjectorComponentActivator : IComponentActivator
{
private readonly Container container;
private readonly IServiceProvider serviceScope;
public SimpleInjectorComponentActivator(Container container, IServiceProvider serviceScope)
{
this.container = container;
this.serviceScope = serviceScope;
}
public IComponent CreateInstance(Type type) =>
(IComponent)this.GetInstance(type) ?? (IComponent)Activator.CreateInstance(type);
private object GetInstance(Type type)
{
this.container.ApplyServiceScope(this.serviceScope);
return this.container.GetRegistration(type)?.GetInstance();
}
}
public sealed class SimpleInjectorBlazorHubActivator<T> : IHubActivator<T> where T : Hub
{
private readonly Container container;
private readonly IServiceProvider serviceScope;
public SimpleInjectorBlazorHubActivator(Container container, IServiceProvider serviceScope)
{
this.container = container;
this.serviceScope = serviceScope;
}
public T Create()
{
this.container.ApplyServiceScope(this.serviceScope);
return this.container.GetInstance<T>();
}
public void Release(T hub) { }
} For now, this is still quite some code, and some unfortunate ugly hacks, but this can hopefully all be tucked away in the near future. All I need is some people who can test run this prototype. |
I was able to use the new integration code without any modifications and I am also able to successfully inject scoped components (like |
I'm testing this out now, its working pretty great so far. A shame that it requires so many hacks, but the I gotta be able to use simple injector so it is worth the trouble 😄. One issue (not sure if it is an issue) the I was able to reproduce it in a stripped down project. Just using the code you provided above for property injection and integration. Made a base component: public class BaseComponent : ComponentBase
{
[Dependency]
private WeatherForecastService _weatherService { get; set; }
protected async Task<WeatherForecast[]> GetForecasts()
{
return await _weatherService.GetForecastAsync(DateTime.Now);
}
} Then made a component that used that base component
When you execute the method |
This will be temporary. It will make sure that integration will become easier over time.
You ran into an unfortunate bug. Simple Injector incorrectly calls .NET's RuntimeReflectionExtensions.GetRuntimeProperties(Type) method, which only returns properties that are visible to the given type. The correct behavior would be to get all properties that are defined on the type and its all its base types, independently of their access modifier. I added it to the v5.3 milestone. Until this bug gets fixed, as a workaround, you can make the property |
But the good news is that this is a problem that you not have, thanks to the design of your application. I'll explain why. Your But even worse, Blazor does not prevent parallel calls by the user. This means that multiple threads can access your The solution is to ensure a new Unless you have a properly designed application—which is what you have. In your case you have two options:
If yo do this in your public List<T> All<T>()
{
using (AsyncScopedLifestyle.BeginScope(_container))
{
var getter = _container.GetInstance<IQueryHandler<GetAllQuery<T>, List<T>>>();
return getter.Handle(GetAllQuery<T>.Instance);
}
} Decorator is perhaps a bit more work. Here's an example of how to do this. p.s. I noticed you were using a lot of reflection inside the p.p.s. you should ditch the |
Thanks for all your research into this. I definitely have learned a lot from this. A couple notes.
|
Transient does not solve the problem. Even a Transient component will stay alive as long the Blazor component it gets injected into. Besides, it could lead to other complications, such as having multiple instances while executing a single request. Instead, you should prevent a DbContext from becoming a Captive Dependency. You can do this by preventing its usage outside the explicitly created scope of your processor. |
Hello, Thank you for this great framework and your commitment! I think there might still be a problem with scoping or related to/the same as the issue with Blazor events. I have a repo here In the Blazor Source the I think I can work around |
Thanks for this detailed repro. The problem seems indeed identical as before; the lack of ability to intercept Blazor events. This causes the Simple Injector scope to be unrelated to the Blazor scope, which means a new I'm afraid we have to wait for Microsoft to add an interception point here. It's the Conforming Container again that "is leading [...] framework developers to stop thinking about defining the right library and the right framework abstractions" as I described here years ago. |
Okay, I think i got it working somehow. There seems to be one place, where the scope can be applied: inside the public class SimpleInjectorEventHandlerScopeProvider
{
private readonly IServiceProvider _serviceScope;
private readonly Container _container;
public SimpleInjectorEventHandlerScopeProvider(
IServiceProvider serviceScope, Container container)
{
_serviceScope = serviceScope;
_container = container;
}
public void ApplyScope()
{
_container.ApplyServiceScope(_serviceScope);
}
} options.Services.AddScoped<SimpleInjectorEventHandlerScopeProvider>(); Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object arg)
{
_handlerFactory.ApplyScope(); //<--- here!
var task = callback.InvokeAsync(arg);
var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
task.Status != TaskStatus.Canceled;
// After each event, we synchronously re-render (unless !ShouldRender())
// This just saves the developer the trouble of putting "StateHasChanged();"
// at the end of every event callback.
StateHasChanged();
return shouldAwaitTask ?
CallStateHasChangedOnAsyncCompletion(task) :
Task.CompletedTask;
} With this unfortunate modification the cross-wired |
Is there missing an interface on |
No, the using Microsoft.AspNetCore.Components;
using System.Threading.Tasks;
namespace BlazorSimpleInjector.Pages
{
public class BaseComponent : ComponentBase, IHandleEvent
{
public BaseComponent(SimpleInjectorEventHandlerScopeProvider scopeProvider)
{
_scopeProvider = scopeProvider;
}
private readonly SimpleInjectorEventHandlerScopeProvider _scopeProvider;
Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object arg)
{
_scopeProvider.ApplyScope(); //<-- here
var task = callback.InvokeAsync(arg);
var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
task.Status != TaskStatus.Canceled;
// After each event, we synchronously re-render (unless !ShouldRender())
// This just saves the developer the trouble of putting "StateHasChanged();"
// at the end of every event callback.
StateHasChanged();
return shouldAwaitTask ?
CallStateHasChangedOnAsyncCompletion(task) :
Task.CompletedTask;
}
private async Task CallStateHasChangedOnAsyncCompletion(Task task)
{
try
{
await task;
}
catch // avoiding exception filters for AOT runtime support
{
// Ignore exceptions from task cancellations, but don't bother issuing a state change.
if (task.IsCanceled)
{
return;
}
throw;
}
StateHasChanged();
}
}
public partial class FetchData : BaseComponent
{
public FetchData(IRequestProcessor requestHandler,
SimpleInjectorEventHandlerScopeProvider scopeProvider) : base(scopeProvider)
{
_requestHandler = requestHandler;
}
private readonly IRequestProcessor _requestHandler;
async Task Navigate()
{
await _requestHandler.Handle(new Request<Foo, Result<Foo>>
{
Model = new Foo()
{
SomeProperty = "test"
}
});
}
}
} |
Hi guys, I'm happy to announce the first version of the Blazor Server App Integration page in the Simple Injector documentation. This pages combines all knowledge gathered here in this thread using your help. If you find any new issues, please let me know. Hopefully we can improve the guidance once more and hopefully, Microsoft improves Blazor soon, which would improve our integration as well. Thanks again. |
I'm just dipping my toes in Blazor and I've followed the integration page. Injection seems to work, but I'm facing issues with the injected After debugging this issue all evening, I've discovered that the More information about authentication in Blazor Server: https://docs.microsoft.com/en-us/aspnet/core/blazor/security/?view=aspnetcore-6.0#authenticationstateprovider-service. The user's information is available through other means like the "cascading parameter" as shown in this document. This is probably related to this issue in aspnetcore. To test this, I have subclassed Update: after debugging some more, it seems I do get injected the correct @using System.Diagnostics
@code {
[Inject] AuthenticationStateProvider FrameworkAuthenticationStateProvider { get; set; }
[MyApp.Dependency] AuthenticationStateProvider ContainerAuthenticationStateProvider { get; set; }
protected override void OnInitialized()
{
// Beware this note about AuthenticationStateProvider:
// > The main drawback to using AuthenticationStateProvider directly is that the component
// > isn't notified automatically if the underlying authentication state data changes.
Debug.Assert(FrameworkAuthenticationStateProvider != ContainerAuthenticationStateProvider,
"Somehow the same instance was returned, making this hack unneeded");
if (ContainerAuthenticationStateProvider is IHostEnvironmentAuthenticationStateProvider hosted)
hosted.SetAuthenticationState(FrameworkAuthenticationStateProvider.GetAuthenticationStateAsync());
}
} |
If you are using Edit: Just a guess: what happens, if you remove the |
By framework I mean ASP.NET Core 6. Injecting
Doesn't make a difference. |
Yes, if you are using Some other thing, which might be of interest: If you are using |
Can you share some of your code showing how you use |
Yes, but really nothing fancy:
used in some services/handlers through constructor injection. And used in one component. But this gets called on every new Page and retrieves some values every time:
Edit: Where and When does it throw? Do you hit the |
I'm registering this class like this in SimpleInjector: @code {
[Dependency] UserInfoService UserInfoService { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
var isAuthenticated = await UserInfoService.UserIsAuthenticatedAsync();
}
} This causes the exception mentioned above ("GetAuthenticationStateAsync was called before SetAuthenticationState.").
Yes. |
I believe the same scoping problem also happens when injecting
When |
I can't kill it. Please provide a sample project to reproduce this behavior. FWIW here is a minimal working example for .net 6 SimpleInjectorIntegration.cs
Program.cs
App.razor
|
Trying to come up with a minimal repro I've found the issue; I had As the culprit of this issue is hard to debug, I think it would be good to either change the default or tell users that they need to change the defaults when using Blazor. |
Hi!
Thanks for a great framework! I've used it in the past, and hope to get it working with my current Razor Components project.
Steps to recreate:
dotnet new razorcomponents -o MyRazorComponents
Startup.cs
to match the documentation as much as possible. The before and after diff can be found here.dotnet watch run
.Verify()
passing:I'm unfamiliar with crosswiring and ASP.NET; please let me know if I made any errors. Blazor initially did not support custom service providers, but that was fixed recently. AutoFac seems to work, so I feel like I'm missing something. I'm using SimpleInjector v4.4.3 (and Integration.AspNetCore.Mvc v4.4.3). Thanks again!
The text was updated successfully, but these errors were encountered: