Skip to content
Open
Show file tree
Hide file tree
Changes from 17 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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Bit.Brouter;
/// <summary>
/// Declares a single route inside a <see cref="Brouter"/>.
/// </summary>
public class BrouterRoute : ComponentBase, IDisposable
public class Broute : ComponentBase, IDisposable
{
/// <summary>
/// The route path to match. Supports literal segments, parameter segments, constraints and wildcards.
Expand Down Expand Up @@ -49,21 +49,33 @@ public class BrouterRoute : ComponentBase, IDisposable
/// <summary>Optional metadata. Exposed via the cascading <c>RouteMeta</c> value.</summary>
[Parameter] public object? Meta { get; set; }

/// <summary>
/// When <c>true</c>, the matched route parameters (and query-string values) are bound to the
/// rendered <see cref="Component"/>'s conventional <c>[Parameter]</c> properties <em>by name</em>,
/// Blazor-style, in addition to any <c>[BrouterParameter]</c>/<c>[BrouterQuery]</c> annotated
/// properties. This is what makes plain <c>@page</c> components (which bind route values to
/// <c>[Parameter]</c> properties, and query values via <c>[SupplyParameterFromQuery]</c>) render
/// correctly. It is enabled automatically for attribute-discovered routes
/// (see <see cref="Brouter.AppAssembly"/> / <see cref="Brouter.AdditionalAssemblies"/>).
/// Defaults to <c>false</c> so existing <c>[BrouterParameter]</c>-only components are unaffected.
/// </summary>
[Parameter] public bool BindComponentParametersByName { get; set; }

/// <summary>Child routes (used for nesting).</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }


[CascadingParameter(Name = "Brouter")] internal Brouter? Brouter { get; set; }
[CascadingParameter(Name = "ParentRoute")] internal BrouterRoute? Parent { get; set; }
[CascadingParameter(Name = "ParentRoute")] internal Broute? Parent { get; set; }
[CascadingParameter(Name = "RouteParameters")] internal BrouterRouteParameters? InheritedParameters { get; set; }


internal string FullTemplate { get; private set; } = string.Empty;


private readonly List<BrouterRoute> _children = [];
internal void AddChild(BrouterRoute route) => _children.Add(route);
internal void RemoveChild(BrouterRoute route) => _children.Remove(route);
private readonly List<Broute> _children = [];
internal void AddChild(Broute route) => _children.Add(route);
internal void RemoveChild(Broute route) => _children.Remove(route);

internal BrouterOutlet? Outlet { get; set; }

Expand Down Expand Up @@ -107,7 +119,10 @@ protected override void OnInitialized()
FullTemplate = $"{Parent.FullTemplate.TrimEnd('/')}/{Path.TrimStart('/')}";
}

RouteTemplate = BrouterTemplateParser.ParseTemplate(FullTemplate);
// Resolve constraints against this Brouter's DI-container-scoped registry (custom constraints
// registered via BrouterOptions.Constraints), falling back to built-ins and the process-wide
// registry. Brouter is non-null here (checked above).
RouteTemplate = BrouterTemplateParser.ParseTemplate(FullTemplate, Brouter.Options.Constraints);
Comment thread
msynk marked this conversation as resolved.

// Precompute Specificity / Depth / IsIndex once. These are stable for the lifetime
// of the route (template and parent chain don't change after registration), so the
Expand All @@ -123,6 +138,17 @@ protected override void OnInitialized()

IsIndex = Parent is not null && string.IsNullOrEmpty(Path.Trim('/'));

// Precompute the set of parameter names declared in this route's template. Used only by the
// conventional (by-name) component binding path to decide which [Parameter] properties on the
// rendered Component correspond to an actual route parameter - so unrelated component parameters
// are left untouched rather than forced to their default on every render.
var templateParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var seg in RouteTemplate.TemplateSegments)
{
if (seg.IsParameter) templateParamNames.Add(seg.Value);
}
TemplateParameterNames = templateParamNames;

_renderer = new BrouterRouteRenderer(this);

Brouter.RegisterRoute(this);
Expand All @@ -145,6 +171,12 @@ protected override void OnInitialized()
/// <remarks>Cached at construction. See <see cref="Specificity"/>.</remarks>
internal bool IsIndex { get; private set; }

/// <summary>
/// The parameter names declared in this route's template (case-insensitive). Cached at construction
/// and consumed by the conventional by-name component binding (<see cref="BindComponentParametersByName"/>).
/// </summary>
internal IReadOnlySet<string>? TemplateParameterNames { get; private set; }


internal bool Matched { get; set; }

Expand All @@ -166,7 +198,7 @@ internal void SetMatched()
internal async ValueTask<bool> InvokeGuardsAsync(BrouterNavigationContext ctx)
{
// Walk from root to leaf so parents authorize children, mirroring Angular's hierarchical guards.
var chain = new List<BrouterRoute>();
var chain = new List<Broute>();
for (var r = this; r is not null; r = r.Parent) chain.Add(r);
chain.Reverse();

Expand Down
85 changes: 85 additions & 0 deletions src/Brouter/Bit.Brouter/BroutePrerenderState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;

namespace Bit.Brouter;

/// <summary>
/// The serialized form of a single loader result carried across the SSR/prerender -&gt; interactive
/// boundary. The concrete runtime type name is stored alongside the JSON so the value can be
/// rehydrated into the exact type the loader produced (rather than a raw <see cref="JsonElement"/>),
/// which is what components consuming the cascading <c>RouteData</c> expect.
/// </summary>
internal sealed class PersistedLoaderState
{
/// <summary>Assembly-qualified name of the loaded value's runtime type. Null when the loader returned null.</summary>
public string? TypeName { get; set; }

/// <summary>The loaded value serialized as JSON. Null when the loader returned null.</summary>
public string? Json { get; set; }
}

/// <summary>
/// Bridges route <see cref="Broute.Loader"/> results across the prerender -&gt; interactive transition so a
/// loader that ran on the server isn't re-run (double-fetched) when the component becomes interactive.
/// Serialization is reflection/JSON based, hence trim/AOT-unsafe for arbitrary types; this is only reached
/// when the consumer opts in via <see cref="BrouterOptions.PersistLoaderState"/> and takes responsibility
/// for keeping their loader data types serializable and preserved.
/// </summary>
internal static class BroutePrerenderState
{
// Web defaults mirror the conventions Blazor itself uses for persisted component state and for
// JSON over the wire, so a single symmetric options instance is used for both directions.
private static readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web);

/// <summary>
/// Builds the persistence key for a loader in the matched chain. It is derived purely from the URL
/// (path + query) and the node's position in the matched chain, both of which are identical on the
/// prerender and interactive passes for the same navigation, so keys line up across the boundary.
/// </summary>
internal static string MakeKey(string path, string query, int chainIndex) =>
$"Bit.Brouter|{path}|{query}|{chainIndex}";

/// <summary>Captures a loader result into its persistable form.</summary>
[RequiresUnreferencedCode("Serializes an arbitrary loader result via System.Text.Json reflection.")]
[RequiresDynamicCode("Serializes an arbitrary loader result via System.Text.Json reflection.")]
internal static PersistedLoaderState Capture(object? value)
{
if (value is null) return new PersistedLoaderState { TypeName = null, Json = null };

var type = value.GetType();
return new PersistedLoaderState
{
TypeName = type.AssemblyQualifiedName,
Json = JsonSerializer.Serialize(value, type, _options),
};
}

/// <summary>
/// Rehydrates a previously-captured loader result. Returns <c>true</c> when a value (possibly null)
/// was restored and the loader should be skipped; <c>false</c> when restoration wasn't possible
/// (unknown type, malformed JSON) and the loader should run normally.
/// </summary>
[RequiresUnreferencedCode("Deserializes a loader result into its runtime type via System.Text.Json reflection.")]
[RequiresDynamicCode("Deserializes a loader result into its runtime type via System.Text.Json reflection.")]
internal static bool TryRestore(PersistedLoaderState? state, out object? value)
{
value = null;
if (state is null) return false;

// A persisted null result is still a decision the loader made: honor it and skip re-running.
if (string.IsNullOrEmpty(state.TypeName) || state.Json is null) return true;

var type = Type.GetType(state.TypeName, throwOnError: false);
if (type is null) return false; // type not available here; fall back to running the loader

try
{
value = JsonSerializer.Deserialize(state.Json, type, _options);
return true;
}
catch (JsonException)
{
return false;
}
}
Comment thread
msynk marked this conversation as resolved.
Outdated
}
117 changes: 117 additions & 0 deletions src/Brouter/Bit.Brouter/BrouteScanner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace Bit.Brouter;

/// <summary>
/// Discovers attribute-routed components (<c>@page</c> / <c>[Route]</c>) across one or more assemblies,
/// translating each declared route template into the form Bit.Brouter matches against. This is what lets
/// routes live colocated with their pages instead of being hand-declared as one big <see cref="Broute"/>
/// tree, mirroring the built-in <c>Router.AppAssembly</c> / <c>AdditionalAssemblies</c> model (including
/// lazily-loaded assemblies added at runtime).
/// </summary>
internal static class BrouteScanner
{
/// <summary>A single route discovered from a <c>[Route]</c> attribute on a routable component.</summary>
internal readonly record struct DiscoveredRoute(
string Template,
[property: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type ComponentType);

/// <summary>
/// Scans <paramref name="appAssembly"/> and <paramref name="additionalAssemblies"/> for public or
/// internal component types annotated with one or more <see cref="RouteAttribute"/> and returns the
/// discovered routes. Each <c>[Route]</c> on a component yields one entry, so a component with several
/// route attributes contributes several routes. Duplicate assemblies are scanned only once.
/// </summary>
/// <remarks>
/// This reflects over every type in the given assemblies, so it is inherently trim-unsafe: the
/// consumer is responsible for keeping their routable components (and the parameter types those
/// components bind) preserved when trimming, exactly as the built-in Blazor Router requires.
/// </remarks>
[RequiresUnreferencedCode(
"Attribute-route discovery reflects over all types in the supplied assemblies to find components " +
"annotated with [Route]/@page. Ensure routable components are preserved when trimming.")]
internal static IReadOnlyList<DiscoveredRoute> Discover(Assembly? appAssembly, IReadOnlyList<Assembly>? additionalAssemblies)
{
// Preserve declaration intent: scan the app assembly first so, on an otherwise-identical
// template tie, an app-level page is registered before one contributed by a referenced
// library (registration order is the final tie-breaker in Brouter.SelectWinner).
var seen = new HashSet<Assembly>();
var results = new List<DiscoveredRoute>();

ScanAssembly(appAssembly, seen, results);
if (additionalAssemblies is not null)
{
for (int i = 0; i < additionalAssemblies.Count; i++)
{
ScanAssembly(additionalAssemblies[i], seen, results);
}
}

return results;
}

[RequiresUnreferencedCode("See Discover.")]
private static void ScanAssembly(Assembly? assembly, HashSet<Assembly> seen, List<DiscoveredRoute> results)
{
if (assembly is null || seen.Add(assembly) is false) return;

// Use GetTypes (not GetExportedTypes): Razor components can be generated as internal, and the
// built-in Router discovers those too. A partially-loadable assembly throws
// ReflectionTypeLoadException but still exposes the types that did load via ex.Types.
Type?[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types;
}

foreach (var type in types)
{
if (type is null || type.IsClass is false || type.IsAbstract) continue;
if (typeof(IComponent).IsAssignableFrom(type) is false) continue;

// inherit: false — a base component's [Route] should not silently spawn routes for every
// derived component. This matches the built-in Router, which reads route attributes declared
// directly on the routable type.
var routeAttributes = type.GetCustomAttributes(typeof(RouteAttribute), inherit: false);
if (routeAttributes.Length == 0) continue;

foreach (var attribute in routeAttributes)
{
var template = ((RouteAttribute)attribute).Template;
results.Add(new DiscoveredRoute(NormalizeTemplate(template), type));
}
}
}

/// <summary>
/// Translates an ASP.NET Core route template into Brouter's template dialect. The only structural
/// difference is catch-all syntax: ASP.NET Core accepts the single-star form <c>{*rest}</c> while
/// Brouter's parser expects the double-star form <c>{**rest}</c>. Everything else (literals,
/// <c>{id:int}</c> constraints, optional <c>{id?}</c> parameters) is already compatible.
/// </summary>
private static string NormalizeTemplate(string? template)
{
if (string.IsNullOrEmpty(template)) return "/";

// Fast path: no single-star catch-all to rewrite.
if (template.IndexOf("{*", StringComparison.Ordinal) < 0) return template;

var segments = template.Split('/');
for (int i = 0; i < segments.Length; i++)
{
var segment = segments[i];
if (segment.StartsWith("{*", StringComparison.Ordinal) &&
segment.StartsWith("{**", StringComparison.Ordinal) is false)
{
segments[i] = "{**" + segment[2..];
}
}

return string.Join('/', segments);
}
}
Loading
Loading