Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ For a migration guide, see [Upgrading bUnit](https://bunit.dev/docs/migrations/i

- Improved renderer logic that catches more edge cases.
- Improved developer experience in relation to JSInterop.
- Implemented feature to map route templates to parameters using NavigationManager. This allows parameters to be set based on the route template when navigating to a new location. Reported by [JamesNK](https://github.com/JamesNK) in [#1580](https://github.com/bUnit-dev/bUnit/issues/1580). By [@linkdotnet](https://github.com/linkdotnet).

## [1.40.0] - 2025-06-14

Expand Down
30 changes: 30 additions & 0 deletions docs/site/docs/providing-input/passing-parameters-to-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,36 @@ A simple example of how to test a component that receives parameters from the qu
}
```

## Setting parameters via routing
In Blazor, components can receive parameters via routing. This is particularly useful for passing data to components based on the URL. To enable this, the component parameters need to be annotated with the `[Parameter]` attribute and the `@page` directive (or `RouteAttribute` in code behind files).

An example component that receives parameters via routing:

```razor
@page "/counter/{initialCount:int}"
<p>Count: @InitialCount</p>
@code {
[Parameter]
public int InitialCount { get; set; }
}
```

To test a component that receives parameters via routing, set the parameters using the `NavigationManager`:

```razor
@inherits TestContext
@code {
[Fact]
public void Component_receives_parameters_from_route()
{
var cut = Render<ExampleComponent>();
var navigationManager = Services.GetRequiredService<NavigationManager>();
navigationManager.NavigateTo("/counter/123");
cut.Find("p").TextContent.ShouldBe("Count: 123");
}
}
```

## Further Reading

- <xref:inject-services>
15 changes: 13 additions & 2 deletions src/bunit/BunitContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ public partial class BunitContext : IDisposable, IAsyncDisposable
/// </summary>
public ComponentFactoryCollection ComponentFactories { get; } = new();

/// <summary>
/// Gets the components that has been rendered by this <see cref="BunitContext"/>.
Copy link

Copilot AI Sep 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO comment should be replaced with proper documentation describing the purpose and usage of the ReturnedRenderedComponents property.

Suggested change
/// Gets the components that has been rendered by this <see cref="BunitContext"/>.
/// Gets the set of rendered components that have been returned by this test context.
/// This is used internally to track components created during testing, enabling proper disposal and management.

Copilot uses AI. Check for mistakes.
/// </summary>
internal ISet<IRenderedComponent<IComponent>> ReturnedRenderedComponents { get; } = new HashSet<IRenderedComponent<IComponent>>();

/// <summary>
/// Initializes a new instance of the <see cref="BunitContext"/> class.
/// </summary>
Expand Down Expand Up @@ -131,7 +136,11 @@ protected virtual void Dispose(bool disposing)
/// <summary>
/// Disposes all components rendered via this <see cref="BunitContext"/>.
/// </summary>
public Task DisposeComponentsAsync() => Renderer.DisposeComponents();
public Task DisposeComponentsAsync()
{
ReturnedRenderedComponents.Clear();
return Renderer.DisposeComponents();
}

/// <summary>
/// Instantiates and performs a first render of a component of type <typeparamref name="TComponent"/>.
Expand Down Expand Up @@ -206,7 +215,9 @@ private IRenderedComponent<TComponent> RenderInsideRenderTree<TComponent>(Render
where TComponent : IComponent
{
var baseResult = RenderInsideRenderTree(renderFragment);
return Renderer.FindComponent<TComponent>(baseResult);
var component = Renderer.FindComponent<TComponent>(baseResult);
ReturnedRenderedComponents.Add((IRenderedComponent<IComponent>)component);
return component;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public sealed class BunitNavigationManager : NavigationManager
{
private readonly BunitContext bunitContext;
private readonly Stack<NavigationHistory> history = new();
private readonly ComponentRouteParameterService componentRouteParameterService;

/// <summary>
/// The navigation history captured by the <see cref="BunitNavigationManager"/>.
Expand All @@ -31,6 +32,7 @@ public sealed class BunitNavigationManager : NavigationManager
public BunitNavigationManager(BunitContext bunitContext)
{
this.bunitContext = bunitContext;
componentRouteParameterService = new ComponentRouteParameterService(bunitContext);
Initialize("http://localhost/", "http://localhost/");
}

Expand Down Expand Up @@ -71,6 +73,7 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
}

Uri = absoluteUri.OriginalString;
componentRouteParameterService.UpdateComponentsWithRouteParameters(absoluteUri);

// Only notify of changes if user navigates within the same
// base url (domain). Otherwise, the user navigated away
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
namespace Bunit.TestDoubles;

using System.Globalization;
using System.Reflection;

internal sealed class ComponentRouteParameterService
{
private readonly BunitContext bunitContext;

/// <summary>
/// Initializes a new instance of the <see cref="ComponentRouteParameterService"/> class.
/// </summary>
public ComponentRouteParameterService(BunitContext bunitContext)
{
this.bunitContext = bunitContext;
}

/// <summary>
/// Triggers the components to update their parameters based on the route parameters.
/// </summary>
public void UpdateComponentsWithRouteParameters(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);

var relativeUri = uri.PathAndQuery;

foreach (var renderedComponent in bunitContext.ReturnedRenderedComponents)
{
var instance = renderedComponent.Instance;
var routeAttributes = GetRouteAttributesFromComponent(instance);

if (routeAttributes.Length == 0)
{
continue;
}

foreach (var template in routeAttributes.Select(r => r.Template))
{
var parameters = GetParametersFromTemplateAndUri(template, relativeUri, instance);
if (parameters.Count > 0)
{
bunitContext.Renderer.SetDirectParametersAsync(renderedComponent, ParameterView.FromDictionary(parameters));
}
}
}
}

private static RouteAttribute[] GetRouteAttributesFromComponent(IComponent instance) =>
instance.GetType()
.GetCustomAttributes(typeof(RouteAttribute), true)
.Cast<RouteAttribute>()
.ToArray();

private static Dictionary<string, object?> GetParametersFromTemplateAndUri(string template, string relativeUri, IComponent instance)
{
var templateSegments = template.Trim('/').Split("/");
var uriSegments = relativeUri.Trim('/').Split("/");

if (templateSegments.Length > uriSegments.Length)
{
return [];
}
Comment on lines +59 to +62
Copy link

Copilot AI Sep 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic doesn't handle optional parameters correctly. A template with optional parameters could be longer than the URI but still be a valid match. Consider checking if the extra template segments are optional before returning an empty dictionary.

Copilot uses AI. Check for mistakes.

var parameters = new Dictionary<string, object?>();

for (var i = 0; i < templateSegments.Length; i++)
{
var templateSegment = templateSegments[i];
if (templateSegment.StartsWith('{') && templateSegment.EndsWith('}'))
{
var parameterName = GetParameterName(templateSegment);
var property = GetParameterProperty(instance, parameterName);

if (property is null)
{
continue;
}

var isCatchAllParameter = templateSegment[1] == '*';
parameters[property.Name] = isCatchAllParameter
? string.Join("/", uriSegments.Skip(i))
: GetValue(uriSegments[i], property);
}
else if (templateSegment != uriSegments[i])
{
return [];
}
}

return parameters;
}

private static string GetParameterName(string templateSegment) =>
templateSegment
.Trim('{', '}', '*')
.Replace("?", string.Empty, StringComparison.OrdinalIgnoreCase)
.Split(':')[0];

private static PropertyInfo? GetParameterProperty(object instance, string propertyName)
{
var propertyInfos = instance.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance);

return Array.Find(propertyInfos, prop => prop.GetCustomAttributes(typeof(ParameterAttribute), true).Length > 0 &&
string.Equals(prop.Name, propertyName, StringComparison.OrdinalIgnoreCase));
}

private static object GetValue(string value, PropertyInfo property)
{
var underlyingType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture);
}
Comment on lines +108 to +112
Copy link

Copilot AI Sep 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Convert.ChangeType call can throw exceptions for invalid conversions. This method should handle conversion failures gracefully, especially for user-provided URL segments that might not be valid for the target type.

Suggested change
private static object GetValue(string value, PropertyInfo property)
{
var underlyingType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture);
}
private static object? GetValue(string value, PropertyInfo property)
{
var underlyingType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
try
{
return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture);
}
catch (FormatException)
{
return GetDefaultValue(underlyingType);
}
catch (InvalidCastException)
{
return GetDefaultValue(underlyingType);
}
catch (OverflowException)
{
return GetDefaultValue(underlyingType);
}
}
private static object? GetDefaultValue(Type type)
{
// Return null for reference types and Nullable<T>, otherwise default(T)
if (!type.IsValueType || Nullable.GetUnderlyingType(type) != null)
return null;
return Activator.CreateInstance(type);
}

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
namespace Bunit.TestDoubles;

public partial class RouterTests
{
[Route("/page/{count:int}/{name}")]
private sealed class ComponentWithPageAttribute : ComponentBase
{
[Parameter] public int Count { get; set; }
[Parameter] public string Name { get; set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "p");
builder.AddContent(1, Count);
builder.AddContent(2, " / ");
builder.AddContent(3, Name);
builder.CloseElement();
}
}

[Route("/page")]
[Route("/page/{count:int}")]
private sealed class ComponentWithMultiplePageAttributes : ComponentBase
{
[Parameter] public int Count { get; set; }
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "p");
builder.AddContent(1, Count);
builder.CloseElement();
}
}

[Route("/page/{count:int}")]
private sealed class ComponentWithOtherParameters : ComponentBase
{
[Parameter] public int Count { get; set; }
[Parameter] public int OtherNumber { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "p");
builder.AddContent(1, Count);
builder.AddContent(2, "/");
builder.AddContent(3, OtherNumber);
builder.CloseElement();
}
}

[Route("/page/{*pageRoute}")]
private sealed class ComponentWithCatchAllRoute : ComponentBase
{
[Parameter] public string PageRoute { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "p");
builder.AddContent(1, PageRoute);
builder.CloseElement();
}
}

[Route("/page/{count:int}")]
private sealed class ComponentWithCustomOnParametersSetAsyncsCall : ComponentBase
{
[Parameter] public int Count { get; set; }
[Parameter] public int IncrementOnParametersSet { get; set; }

protected override void OnParametersSet()
{
Count += IncrementOnParametersSet;
}

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "p");
builder.AddContent(1, Count);
builder.CloseElement();
}
}

[Route("/page/{count?:int}")]
private sealed class ComponentWithOptionalParameter : ComponentBase
{
[Parameter] public int? Count { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "p");
builder.AddContent(1, Count);
builder.CloseElement();
}
}

[Route("/page/{count:int}")]
private sealed class ComponentThatNavigatesToSelfOnButtonClick : ComponentBase
{
[Parameter] public int Count { get; set; }

[Inject] private NavigationManager NavigationManager { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "button");
builder.AddAttribute(1, "onclick", EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo($"/page/{Count + 1}")));
builder.AddContent(2, "Increment");
builder.CloseElement();
builder.OpenElement(3, "p");
builder.AddContent(4, Count);
builder.CloseElement();
}
}

[Route("/page/{count:int}")]
private sealed class ComponentThatNavigatesToSelfOnButtonClickIntercepted : ComponentBase
{
[Parameter] public int Count { get; set; }

[Inject] private NavigationManager NavigationManager { get; set; }

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "button");
builder.AddAttribute(1, "onclick", EventCallback.Factory.Create(this, () => NavigationManager.NavigateTo($"/page/{Count + 1}")));
builder.AddContent(2, "Increment");
builder.CloseElement();
builder.OpenElement(3, "p");
builder.AddContent(4, Count);
builder.CloseElement();
builder.OpenComponent<NavigationLock>(5);
builder.AddAttribute(6, "OnBeforeInternalNavigation",
EventCallback.Factory.Create<LocationChangingContext>(this,
InterceptNavigation
));
builder.CloseComponent();
}

private static void InterceptNavigation(LocationChangingContext context)
{
context.PreventNavigation();
}
}
}
Loading
Loading