diff --git a/.gitignore b/.gitignore
index 7cad5f0132..b27f91ed39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -266,6 +266,9 @@ _book
/src/Butil/Bit.Butil/Scripts/**/*.js
/src/Butil/Bit.Butil/wwwroot/**/*.js
+/src/Brouter/Bit.Brouter/Scripts/**/*.js
+/src/Brouter/Bit.Brouter/wwwroot/**/*.js
+
/src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/BitFileUploaderFiles/*.*
/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/wwwroot/scripts/app.js
diff --git a/src/Brouter/Bit.Brouter.Generators/AnalyzerReleases.Shipped.md b/src/Brouter/Bit.Brouter.Generators/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000000..06154fcda8
--- /dev/null
+++ b/src/Brouter/Bit.Brouter.Generators/AnalyzerReleases.Shipped.md
@@ -0,0 +1 @@
+; Shipped analyzer releases; see https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
diff --git a/src/Brouter/Bit.Brouter.Generators/AnalyzerReleases.Unshipped.md b/src/Brouter/Bit.Brouter.Generators/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000000..a2ae3e4c54
--- /dev/null
+++ b/src/Brouter/Bit.Brouter.Generators/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,7 @@
+; Unshipped analyzer releases; see https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+BRT001 | Bit.Brouter.Generators | Warning | Duplicate route template declared with conflicting names
diff --git a/src/Brouter/Bit.Brouter.Generators/Bit.Brouter.Generators.csproj b/src/Brouter/Bit.Brouter.Generators/Bit.Brouter.Generators.csproj
new file mode 100644
index 0000000000..cc8a12d512
--- /dev/null
+++ b/src/Brouter/Bit.Brouter.Generators/Bit.Brouter.Generators.csproj
@@ -0,0 +1,38 @@
+
+
+
+
+
+ netstandard2.0
+
+ true
+ true
+ false
+ true
+
+ $(NoWarn);nullable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Brouter/Bit.Brouter.Generators/BrouterRoutesGenerator.cs b/src/Brouter/Bit.Brouter.Generators/BrouterRoutesGenerator.cs
new file mode 100644
index 0000000000..312ba04b72
--- /dev/null
+++ b/src/Brouter/Bit.Brouter.Generators/BrouterRoutesGenerator.cs
@@ -0,0 +1,291 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Bit.Brouter.Generators;
+
+///
+/// Generates BrouterRoutes - one compile-time-safe URL builder method per route declared in
+/// the project's .razor files (@page, @attribute [Route] and literal
+/// <Broute Path="..."> trees). Route parameters become typed method parameters using
+/// the template's constraints ({id:int} → int id), optionals become optional
+/// arguments, catch-alls become path strings, and values are escaped/formatted with the exact
+/// rules the router itself uses - so a generated URL always round-trips through its template.
+///
+[Generator]
+public sealed class BrouterRoutesGenerator : IIncrementalGenerator
+{
+ private static readonly DiagnosticDescriptor _conflictingRouteNames = new(
+ id: "BRT001",
+ title: "Duplicate route template with conflicting names",
+ messageFormat: "The route template \"/{0}\" is declared more than once with different names (\"{1}\" and \"{2}\"); only \"{1}\" gets a URL builder and Names constant",
+ category: "Bit.Brouter.Generators",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var routesPerFile = context.AdditionalTextsProvider
+ .Where(static text => text.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase))
+ .Select(static (text, ct) =>
+ {
+ var content = text.GetText(ct)?.ToString() ?? string.Empty;
+ return new EquatableArray(RazorRouteScanner.Scan(content).ToArray());
+ })
+ .Where(static routes => routes.Count > 0)
+ .Collect();
+
+ var rootNamespace = context.AnalyzerConfigOptionsProvider
+ .Select(static (provider, _) =>
+ provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns) && string.IsNullOrWhiteSpace(ns) is false
+ ? ns
+ : "Bit.Brouter.Generated");
+
+ context.RegisterSourceOutput(routesPerFile.Combine(rootNamespace), static (spc, input) =>
+ {
+ var (perFile, ns) = input;
+ var source = Emit(perFile, ns, spc);
+ if (source is not null)
+ {
+ spc.AddSource("BrouterRoutes.g.cs", SourceText.From(source, Encoding.UTF8));
+ }
+ });
+ }
+
+ private static string? Emit(ImmutableArray> perFile, string ns, SourceProductionContext spc)
+ {
+ // Dedup by canonical template (case-insensitive; parameter names don't distinguish
+ // templates at runtime, but for generation the first declaration's names/types win -
+ // matching the router's "hand-declared shadows discovered" precedence closely enough).
+ // A named declaration is preferred over an unnamed duplicate so Names constants survive.
+ var byTemplate = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var file in perFile)
+ {
+ foreach (var route in file.Items)
+ {
+ if (byTemplate.TryGetValue(route.Template, out var existing))
+ {
+ // Two different explicit names on the same template is a declaration bug the
+ // first-wins dedup would otherwise hide - surface it instead of guessing.
+ if (existing.Name is not null && route.Name is not null
+ && string.Equals(existing.Name, route.Name, StringComparison.Ordinal) is false)
+ {
+ spc.ReportDiagnostic(Diagnostic.Create(
+ _conflictingRouteNames, Location.None, route.Template, existing.Name, route.Name));
+ }
+ if (existing.Name is null && route.Name is not null) byTemplate[route.Template] = route;
+ continue;
+ }
+ byTemplate[route.Template] = route;
+ }
+ }
+
+ if (byTemplate.Count == 0) return null;
+
+ var routes = byTemplate.Values.OrderBy(r => r.Template, StringComparer.Ordinal).ToList();
+
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine($"namespace {ns};");
+ sb.AppendLine();
+ sb.AppendLine("/// Compile-time-safe URL builders for this app's Brouter routes.");
+ sb.AppendLine("public static partial class BrouterRoutes");
+ sb.AppendLine("{");
+
+ // Method-name assignment happens in two passes so an explicitly named route always owns
+ // its own name: "Name=counter" must yield Counter(...) even when an unnamed "/counter"
+ // template would derive the same identifier (that one gets the numeric suffix instead).
+ var usedNames = new HashSet(StringComparer.Ordinal);
+ var methodNames = new Dictionary();
+ foreach (var route in routes)
+ {
+ if (route.Name is not null) methodNames[route] = MakeUnique(MethodNameFor(route), usedNames);
+ }
+ foreach (var route in routes)
+ {
+ if (route.Name is null) methodNames[route] = MakeUnique(MethodNameFor(route), usedNames);
+ }
+
+ var namedRoutes = new List<(string Constant, string Name)>();
+ foreach (var route in routes)
+ {
+ var methodName = methodNames[route];
+ if (route.Name is not null)
+ {
+ namedRoutes.Add((methodName, route.Name));
+ }
+ EmitMethod(sb, route, methodName);
+ }
+
+ if (namedRoutes.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine(" /// The declared route names, for NavigateToName/ResolveUrl without magic strings.");
+ sb.AppendLine(" public static class Names");
+ sb.AppendLine(" {");
+ var usedConstants = new HashSet(StringComparer.Ordinal);
+ foreach (var (constant, name) in namedRoutes)
+ {
+ var constantName = MakeUnique(constant, usedConstants);
+ sb.AppendLine($" public const string {constantName} = \"{Escape(name)}\";");
+ }
+ sb.AppendLine(" }");
+ }
+
+ EmitHelpers(sb);
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ private static void EmitMethod(StringBuilder sb, RouteModel route, string methodName)
+ {
+ var parameters = new List();
+ var body = new StringBuilder();
+
+ foreach (var segment in route.Segments.Items)
+ {
+ switch (segment.Kind)
+ {
+ case SegmentKind.Literal:
+ body.AppendLine($" sb.Append(\"/{Escape(segment.Value)}\");");
+ break;
+
+ case SegmentKind.Parameter:
+ var paramName = ParameterNameFor(segment.Value);
+ if (segment.IsOptional)
+ {
+ var optionalType = segment.ClrType == "string" ? "string?" : segment.ClrType + "?";
+ parameters.Add($"{optionalType} {paramName} = null");
+ // Trailing optionals: emit only while values are provided (the template
+ // parser guarantees optionals are trailing, so no literal follows).
+ body.AppendLine($" if ({paramName} is not null) sb.Append('/').Append(global::System.Uri.EscapeDataString(__Format({paramName})));");
+ }
+ else
+ {
+ parameters.Add($"{segment.ClrType} {paramName}");
+ body.AppendLine($" sb.Append('/').Append(global::System.Uri.EscapeDataString(__Format({paramName})));");
+ }
+ break;
+
+ case SegmentKind.CatchAll:
+ var catchAllName = ParameterNameFor(segment.Value);
+ parameters.Add($"string? {catchAllName} = null");
+ body.AppendLine($" if (!string.IsNullOrEmpty({catchAllName}))");
+ body.AppendLine(" {");
+ body.AppendLine($" foreach (var __seg in {catchAllName}!.Split(new[] {{ '/' }}, global::System.StringSplitOptions.RemoveEmptyEntries))");
+ body.AppendLine(" {");
+ body.AppendLine(" sb.Append('/').Append(global::System.Uri.EscapeDataString(__seg));");
+ body.AppendLine(" }");
+ body.AppendLine(" }");
+ break;
+ }
+ }
+
+ parameters.Add("string? query = null");
+
+ sb.AppendLine();
+ sb.AppendLine($" /// Builds a URL for the route template \"/{Escape(route.Template)}\".");
+ sb.AppendLine($" public static string {methodName}({string.Join(", ", parameters)})");
+ sb.AppendLine(" {");
+ sb.AppendLine(" var sb = new global::System.Text.StringBuilder();");
+ sb.Append(body);
+ sb.AppendLine(" if (sb.Length == 0) sb.Append('/');");
+ sb.AppendLine(" if (!string.IsNullOrEmpty(query)) sb.Append(query![0] == '?' ? query : \"?\" + query);");
+ sb.AppendLine(" return sb.ToString();");
+ sb.AppendLine(" }");
+ }
+
+ private static void EmitHelpers(StringBuilder sb)
+ {
+ sb.AppendLine();
+ sb.AppendLine(" // Mirrors the router's invariant route-value formatting so generated URLs and");
+ sb.AppendLine(" // ResolveUrl-produced URLs are byte-identical for the same values.");
+ sb.AppendLine(" private static string __Format(object? value)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" if (value is null) return string.Empty;");
+ sb.AppendLine(" if (value is string s) return s;");
+ sb.AppendLine(" if (value is bool b) return b ? \"true\" : \"false\";");
+ sb.AppendLine(" if (value is global::System.DateTime dt) return dt.ToString(\"o\", global::System.Globalization.CultureInfo.InvariantCulture);");
+ sb.AppendLine(" if (value is global::System.IFormattable f) return f.ToString(null, global::System.Globalization.CultureInfo.InvariantCulture);");
+ sb.AppendLine(" return value.ToString() ?? string.Empty;");
+ sb.AppendLine(" }");
+ }
+
+ /// Derives the method name: the explicit route Name when present, else the template's literals (fallback: parameter names).
+ private static string MethodNameFor(RouteModel route)
+ {
+ if (string.IsNullOrWhiteSpace(route.Name) is false)
+ {
+ return Pascalize(route.Name!);
+ }
+
+ var literals = new StringBuilder();
+ foreach (var segment in route.Segments.Items)
+ {
+ if (segment.Kind == SegmentKind.Literal) literals.Append(Pascalize(segment.Value));
+ }
+ if (literals.Length > 0)
+ {
+ // "/users/{id}" and "/users" both start from "Users"; parameterized templates get a
+ // "By{Param}" suffix so the pair reads naturally and rarely collides.
+ foreach (var segment in route.Segments.Items)
+ {
+ if (segment.Kind == SegmentKind.Parameter) literals.Append("By").Append(Pascalize(segment.Value));
+ }
+ return literals.ToString();
+ }
+
+ if (route.Segments.Count == 0) return "Root";
+
+ var fromParams = new StringBuilder("By");
+ foreach (var segment in route.Segments.Items)
+ {
+ if (segment.Kind is SegmentKind.Parameter or SegmentKind.CatchAll) fromParams.Append(Pascalize(segment.Value));
+ }
+ return fromParams.Length > 2 ? fromParams.ToString() : "Route";
+ }
+
+ private static string Pascalize(string value)
+ {
+ var sb = new StringBuilder(value.Length);
+ var upperNext = true;
+ foreach (var c in value)
+ {
+ if (char.IsLetterOrDigit(c) is false)
+ {
+ upperNext = true;
+ continue;
+ }
+ sb.Append(upperNext ? char.ToUpperInvariant(c) : c);
+ upperNext = false;
+ }
+ if (sb.Length == 0) return "Route";
+ if (char.IsDigit(sb[0])) sb.Insert(0, '_');
+ return sb.ToString();
+ }
+
+ private static string ParameterNameFor(string templateParamName)
+ {
+ // Route parameter names are already identifier-shaped (the parser validated them);
+ // lower-case the first letter for C# convention and keyword-proof with '@'.
+ var name = char.ToLowerInvariant(templateParamName[0]) + templateParamName.Substring(1);
+ return "@" + name;
+ }
+
+ private static string MakeUnique(string name, HashSet used)
+ {
+ if (used.Add(name)) return name;
+ for (var i = 2; ; i++)
+ {
+ var candidate = name + i.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ if (used.Add(candidate)) return candidate;
+ }
+ }
+
+ private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+}
diff --git a/src/Brouter/Bit.Brouter.Generators/IsExternalInit.cs b/src/Brouter/Bit.Brouter.Generators/IsExternalInit.cs
new file mode 100644
index 0000000000..60f31a7ec9
--- /dev/null
+++ b/src/Brouter/Bit.Brouter.Generators/IsExternalInit.cs
@@ -0,0 +1,5 @@
+// Polyfill: records/init-only setters require this marker type, which netstandard2.0 lacks.
+namespace System.Runtime.CompilerServices
+{
+ internal static class IsExternalInit { }
+}
diff --git a/src/Brouter/Bit.Brouter.Generators/RazorRouteScanner.cs b/src/Brouter/Bit.Brouter.Generators/RazorRouteScanner.cs
new file mode 100644
index 0000000000..a7e2a82fb0
Binary files /dev/null and b/src/Brouter/Bit.Brouter.Generators/RazorRouteScanner.cs differ
diff --git a/src/Brouter/Bit.Brouter.Generators/RouteModel.cs b/src/Brouter/Bit.Brouter.Generators/RouteModel.cs
new file mode 100644
index 0000000000..e5d11f4507
--- /dev/null
+++ b/src/Brouter/Bit.Brouter.Generators/RouteModel.cs
@@ -0,0 +1,71 @@
+using System.Collections.Generic;
+
+namespace Bit.Brouter.Generators;
+
+///
+/// A route discovered in a .razor file, reduced to what URL generation needs:
+/// Template is the full normalized template ("users/{id}/edit", no surrounding slashes),
+/// Name the explicit route name (from a Broute Name="...") or null, and
+/// Segments the parsed segments in order. Records with a value-equatable segment list so
+/// the incremental pipeline can cache on equality.
+///
+internal sealed record RouteModel(string Template, string? Name, EquatableArray Segments);
+
+///
+/// One template segment: Value is the literal text or parameter name; ClrType the C#
+/// type keyword the (last) constraint maps to ("string" when unconstrained).
+///
+internal sealed record RouteSegment(SegmentKind Kind, string Value, string ClrType, bool IsOptional);
+
+internal enum SegmentKind
+{
+ Literal,
+ Parameter,
+ CatchAll,
+ /// A literal '*' or '**' wildcard - the template can't be resolved into a URL.
+ Wildcard,
+}
+
+///
+/// Minimal immutable array with structural equality, so records holding lists stay cacheable by
+/// the incremental generator infrastructure (plain arrays/ImmutableArray compare by reference).
+///
+internal readonly struct EquatableArray : System.IEquatable>
+ where T : notnull
+{
+ private static readonly T[] _empty = new T[0];
+
+ private readonly T[]? _items;
+
+ public EquatableArray(T[] items) => _items = items;
+
+ public IReadOnlyList Items => _items ?? _empty;
+
+ public int Count => _items?.Length ?? 0;
+
+ public T this[int index] => _items![index];
+
+ public bool Equals(EquatableArray other)
+ {
+ var a = _items ?? _empty;
+ var b = other._items ?? _empty;
+ if (a.Length != b.Length) return false;
+ for (var i = 0; i < a.Length; i++)
+ {
+ if (EqualityComparer.Default.Equals(a[i], b[i]) is false) return false;
+ }
+ return true;
+ }
+
+ public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other);
+
+ public override int GetHashCode()
+ {
+ var hash = 17;
+ foreach (var item in _items ?? _empty)
+ {
+ hash = unchecked(hash * 31 + item.GetHashCode());
+ }
+ return hash;
+ }
+}
diff --git a/src/Brouter/Bit.Brouter.Generators/RouteTemplateParser.cs b/src/Brouter/Bit.Brouter.Generators/RouteTemplateParser.cs
new file mode 100644
index 0000000000..c3a7e165ae
--- /dev/null
+++ b/src/Brouter/Bit.Brouter.Generators/RouteTemplateParser.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+
+namespace Bit.Brouter.Generators;
+
+///
+/// A standalone (generator-side) parser for Brouter/Blazor route templates, deliberately minimal:
+/// it only extracts what URL *generation* needs - segment kinds, parameter names, the CLR type of
+/// the last constraint, optionality. Validation stays the runtime parser's job; anything this
+/// parser can't make sense of yields null and the route is simply skipped by the generator.
+///
+internal static class RouteTemplateParser
+{
+ ///
+ /// Maps a route constraint token to the C# type keyword its converted value has. Mirrors the
+ /// runtime rule that the LAST constraint's conversion wins. Unknown (custom) constraints keep
+ /// the raw string.
+ ///
+ private static string MapConstraint(string constraint) => constraint.ToLowerInvariant() switch
+ {
+ "int" => "int",
+ "long" => "long",
+ "bool" => "bool",
+ "guid" => "global::System.Guid",
+ "datetime" => "global::System.DateTime",
+ "decimal" => "decimal",
+ "double" => "double",
+ "float" => "float",
+ _ => "string",
+ };
+
+ /// Parses into segments, or null when it isn't generatable.
+ public static RouteModel? Parse(string template, string? name)
+ {
+ var normalized = template.Trim().Trim('/');
+ var segments = new List();
+
+ if (normalized.Length > 0)
+ {
+ foreach (var raw in normalized.Split('/'))
+ {
+ var part = raw.Trim();
+ if (part.Length == 0) continue;
+
+ if (part == "*" || part == "**")
+ {
+ segments.Add(new RouteSegment(SegmentKind.Wildcard, part, "string", IsOptional: false));
+ continue;
+ }
+
+ if (part.Length >= 2 && part[0] == '{' && part[part.Length - 1] == '}')
+ {
+ var inner = part.Substring(1, part.Length - 2).Trim();
+ if (inner.Length == 0) return null;
+
+ if (inner.StartsWith("**", StringComparison.Ordinal))
+ {
+ var catchAllName = inner.Substring(2).TrimEnd('?').Trim();
+ if (IsValidParamName(catchAllName) is false) return null;
+ segments.Add(new RouteSegment(SegmentKind.CatchAll, catchAllName, "string", IsOptional: true));
+ continue;
+ }
+
+ var optional = inner.EndsWith("?", StringComparison.Ordinal);
+ if (optional) inner = inner.Substring(0, inner.Length - 1);
+
+ var pieces = inner.Split(':');
+ var paramName = pieces[0].Trim();
+ if (IsValidParamName(paramName) is false) return null;
+
+ // Last constraint wins the conversion, mirroring the runtime.
+ var clrType = pieces.Length > 1 ? MapConstraint(pieces[pieces.Length - 1].Trim()) : "string";
+ segments.Add(new RouteSegment(SegmentKind.Parameter, paramName, clrType, optional));
+ continue;
+ }
+
+ // A literal containing braces mid-string is beyond this parser; skip the route.
+ if (part.IndexOf('{') >= 0 || part.IndexOf('}') >= 0) return null;
+
+ segments.Add(new RouteSegment(SegmentKind.Literal, part, "string", IsOptional: false));
+ }
+ }
+
+ return new RouteModel(normalized, name, new EquatableArray(segments.ToArray()));
+ }
+
+ private static bool IsValidParamName(string name)
+ {
+ if (name.Length == 0) return false;
+ foreach (var c in name)
+ {
+ if (char.IsLetterOrDigit(c) is false && c != '_') return false;
+ }
+ return char.IsDigit(name[0]) is false;
+ }
+}
diff --git a/src/Brouter/Bit.Brouter.slnx b/src/Brouter/Bit.Brouter.slnx
index 02b10d1206..a5c4442248 100644
--- a/src/Brouter/Bit.Brouter.slnx
+++ b/src/Brouter/Bit.Brouter.slnx
@@ -22,6 +22,9 @@
+
+
+
diff --git a/src/Brouter/Bit.Brouter/Bit.Brouter.csproj b/src/Brouter/Bit.Brouter/Bit.Brouter.csproj
index 5404976c45..1b162e2f40 100644
--- a/src/Brouter/Bit.Brouter/Bit.Brouter.csproj
+++ b/src/Brouter/Bit.Brouter/Bit.Brouter.csproj
@@ -33,4 +33,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_BrouterStaleJsStamps Include="obj\bit-brouter-*.stamp" Exclude="obj\bit-brouter-$(Configuration).stamp" />
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Brouter/Bit.Brouter/Broute.cs b/src/Brouter/Bit.Brouter/Broute.cs
new file mode 100644
index 0000000000..1bfffde49e
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/Broute.cs
@@ -0,0 +1,478 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace Bit.Brouter;
+
+///
+/// Declares a single route inside a .
+///
+public class Broute : ComponentBase, IDisposable
+{
+ ///
+ /// The route path to match. Supports literal segments, parameter segments, constraints and wildcards.
+ /// E.g. "/users/{id:int}", "/files/{**path}", "/posts/{slug?}".
+ /// For nested (child) routes, an empty string matches the parent path exactly (index route).
+ ///
+ [Parameter, EditorRequired] public string Path { get; set; } = string.Empty;
+
+ ///
+ /// Marks this as a pathless grouping route: it contributes no URL segments and never
+ /// matches by itself, existing purely to attach shared behavior - a ,
+ /// , , or a layout
+ /// (with a ) - to the routes declared in its
+ /// . Children inherit the surrounding path as if the group weren't
+ /// there. Mirrors SvelteKit's (group) directories and TanStack Router's pathless layout
+ /// routes. A group must not declare a .
+ ///
+ [Parameter] public bool Group { get; set; }
+
+ /// Optional unique name for this route. Used by and .
+ [Parameter] public string? Name { get; set; }
+
+ ///
+ /// When set, navigating to this route redirects to the given URL instead of running loaders or rendering.
+ /// Guards (on this route and its ancestors) still run first, so a guard may cancel the navigation or
+ /// redirect elsewhere; only when guards pass is the redirect to performed.
+ ///
+ [Parameter] public string? RedirectTo { get; set; }
+
+ /// The component type to render when this route matches.
+ [Parameter, DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
+ public Type? Component { get; set; }
+
+ /// A render fragment to render when this route matches. The argument carries the route parameters.
+ [Parameter] public RenderFragment? Content { get; set; }
+
+ ///
+ /// Async guard. Use ctx.Cancel() or ctx.Redirect("/login") to deny.
+ /// Inspired by Vue Router's beforeEnter and Angular's CanActivate.
+ ///
+ [Parameter] public Func? Guard { get; set; }
+
+ ///
+ /// Async leave guard: runs when a navigation would deactivate this route (it is part of the
+ /// currently rendered chain but not of the new one), before OnNavigating and any enter
+ /// s, leaf to root. ctx.Cancel() / ctx.Redirect(...) are
+ /// preventive - the URL never changes on a cancelled leave, enabling real "unsaved changes"
+ /// prompts per route. A navigation that keeps this route matched (e.g. only a parameter or a
+ /// descendant changed) does not fire it. During the leave call, ctx.Route is the route
+ /// being left. Inspired by Vue Router's beforeRouteLeave and Angular's CanDeactivate.
+ ///
+ [Parameter] public Func? LeaveGuard { get; set; }
+
+ ///
+ /// Async data loader. Runs after the route matches and guards pass, before render.
+ /// The result is exposed to the rendered content as an unnamed cascading
+ /// (matched by type) with typed Get<T>/TryGet<T> accessors.
+ /// In a nested chain, loaders run sequentially root -> leaf by default (each parent's
+ /// loader completes before its child's starts); set
+ /// to run independent loaders concurrently instead.
+ /// Inspired by React Router v6's loader and Angular's Resolve.
+ ///
+ [Parameter] public Func>? Loader { get; set; }
+
+ ///
+ /// Optional metadata. Exposed to the rendered content as an unnamed cascading
+ /// (matched by type) with typed Get<T>/TryGet<T> accessors.
+ ///
+ [Parameter] public object? Meta { get; set; }
+
+ ///
+ /// When true, this route's rendered content is kept mounted (hidden) after the user
+ /// navigates away, so returning to it restores the exact component state - scroll inside
+ /// widgets, form input, expanded panels - instead of recreating the component. The Angular
+ /// RouteReuseStrategy / Vue KeepAlive idea, scoped per route. The preserved
+ /// content lives inside a <div hidden> wrapper while inactive, so it costs memory
+ /// and (hidden) DOM for as long as its hosting layout stays mounted; state survives sibling
+ /// navigations under the same layout, not the layout's own unmount. Opt-in per route.
+ ///
+ [Parameter] public bool KeepAlive { get; set; }
+
+ ///
+ /// Retained-instance budget for this route. At the default of 1 (or when
+ /// unset and is 1) the route keeps a single live
+ /// instance that re-binds when its parameter values change - state carries across
+ /// parameter changes. Above 1, instances are kept per matched parameter values: visiting
+ /// /item/1 then /item/2 mounts two separate instances, and returning to
+ /// /item/1 resumes its exact state. When the budget is exceeded the least-recently-used
+ /// hidden instance is evicted (disposed). The key is built from the route's template parameter
+ /// values only - the query string is deliberately not part of it, so ?tab=2 variations
+ /// share one instance. No effect unless is set; values below 1 act as 1.
+ ///
+ [Parameter] public int? KeepAliveMax { get; set; }
+
+ ///
+ /// Freshness window for this route's result, enabling the router's
+ /// stale-while-revalidate cache: a navigation (or Back/Forward) to a URL whose cached result is
+ /// younger than this skips the loader entirely; an older-but-not-garbage-collected result is
+ /// served per (rendered immediately with a
+ /// background refresh by default). Null (the default) falls back to
+ /// , which itself defaults to no caching.
+ /// Cache entries key on the full URL (path + query), so different parameters cache separately.
+ /// Inspired by TanStack Router's staleTime.
+ ///
+ [Parameter] public TimeSpan? StaleTime { get; set; }
+
+ ///
+ /// Error UI for this route. When a commit-phase failure occurs (typically a
+ /// in the matched chain throwing), the nearest ErrorContent - walking from the failed
+ /// route up through its ancestors - renders in place of the routed content, while ancestor
+ /// layouts above the boundary keep rendering normally. The fragment receives a
+ /// with the exception, the location, and a
+ /// RetryAsync() that re-runs the navigation. When no route in the chain declares one,
+ /// the failure bubbles to . The global
+ /// hook fires either way.
+ ///
+ [Parameter] public RenderFragment? ErrorContent { get; set; }
+
+ ///
+ /// When true, the matched route parameters (and query-string values) are bound to the
+ /// rendered 's conventional [Parameter] properties by name,
+ /// Blazor-style, in addition to any [BrouterParameter]/[BrouterQuery] annotated
+ /// properties. This is what makes plain @page components (which bind route values to
+ /// [Parameter] properties, and query values via [SupplyParameterFromQuery]) render
+ /// correctly. It is enabled automatically for attribute-discovered routes
+ /// (see / ).
+ /// Defaults to false so existing [BrouterParameter]-only components are unaffected.
+ ///
+ [Parameter] public bool BindComponentParametersByName { get; set; }
+
+ /// Child routes (used for nesting).
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+
+ [CascadingParameter(Name = "Brouter")] internal Brouter? Brouter { get; set; }
+ [CascadingParameter(Name = "ParentRoute")] internal Broute? Parent { get; set; }
+ [CascadingParameter(Name = "RouteParameters")] internal BrouterRouteParameters? InheritedParameters { get; set; }
+ // True for the synthetic Broutes Brouter emits for attribute-discovered (@page) routes; the
+ // discovered region is wrapped in a fixed cascading value (see Brouter.BuildRenderTree).
+ // RegisterRoute's ambiguity check exempts a hand-declared/discovered pair with the same
+ // template - that's the documented "hand-declared routes win ties over discovered ones"
+ // override pattern - and only rejects same-kind duplicates.
+ [CascadingParameter(Name = "IsDiscoveredRoute")] internal bool IsDiscovered { get; set; }
+
+
+ internal string FullTemplate { get; private set; } = string.Empty;
+
+
+ private readonly List _children = [];
+ internal void AddChild(Broute route) => _children.Add(route);
+ internal void RemoveChild(Broute route) => _children.Remove(route);
+
+ // The s declared inside this route's Content, keyed by outlet name ("" is the
+ // primary outlet, which hosts the matched child's Content/Component; named outlets host the
+ // child's fragments). Same single-dispatcher discipline as everything
+ // else on this type.
+ internal Dictionary Outlets { get; } = new(StringComparer.Ordinal);
+
+ internal bool HasPrimaryOutlet => Outlets.ContainsKey(string.Empty);
+
+ internal void RegisterOutlet(string name, BrouterOutlet outlet) => Outlets[name] = outlet;
+
+ internal void UnregisterOutlet(string name, BrouterOutlet outlet)
+ {
+ // Only detach when the slot still points at this instance; a newer outlet (recreated on
+ // re-render) may already have taken the name over.
+ if (Outlets.TryGetValue(name, out var existing) && ReferenceEquals(existing, outlet))
+ {
+ Outlets.Remove(name);
+ }
+ }
+
+ ///
+ /// Hands the matched child (and its merged parameters) to every outlet this route hosts:
+ /// the primary outlet renders the child's content, named outlets render the child's
+ /// corresponding fragments. Called from the child's renderer on
+ /// every render pass, mirroring the old single-outlet Render call.
+ ///
+ internal void SetOutletChild(Broute child, BrouterRouteParameters parameters)
+ {
+ foreach (var outlet in Outlets.Values)
+ {
+ outlet.Render(child, parameters);
+ }
+ }
+
+ // Named view fragments declared by children of this route, rendered by the
+ // parent's same-named outlets when this route is matched (Vue named-views style). Null until
+ // the first view registers.
+ internal Dictionary>? NamedViews { get; private set; }
+
+ internal void SetNamedView(string name, RenderFragment? fragment)
+ {
+ if (fragment is null)
+ {
+ NamedViews?.Remove(name);
+ }
+ else
+ {
+ (NamedViews ??= new Dictionary>(StringComparer.Ordinal))[name] = fragment;
+ }
+
+ // The fragments render inside the PARENT's outlets; nudge them so view content updated by
+ // a host re-render (a new fragment instance) actually reaches the screen.
+ if (Parent is null) return;
+ foreach (var outlet in Parent.Outlets.Values)
+ {
+ outlet.Refresh();
+ }
+ }
+
+ internal BrouterRouteTemplate? RouteTemplate { get; private set; }
+
+ // The canonical-template key this route was registered under in Brouter's ambiguity dictionary
+ // (see Brouter.RegisterRoute). Stored so UnregisterRoute removes exactly the key that was added,
+ // even if Options.CaseSensitive flips between registration and disposal.
+ internal string? TemplateCollisionKey { get; set; }
+ // Tightened from IDictionary to IReadOnlyDictionary: callers only ever read these and the
+ // pipeline replaces them wholesale on a match commit. Exposing the mutable interface let
+ // any internal caller .Add/.Remove/.Clear them mid-render which would be a footgun against
+ // a route that's still part of an actively-rendering matched chain.
+ internal IReadOnlyDictionary Parameters { get; set; } = new Dictionary();
+ internal IReadOnlyDictionary ConstraintsByParameter { get; set; } = new Dictionary();
+ internal object? LoadedData { get; set; }
+
+ // Set by Brouter.RenderNavigationError when this route is the nearest error boundary for a
+ // failed navigation; the renderer then emits ErrorContent instead of Content/Component.
+ // Only ever set on routes whose ErrorContent is non-null; cleared at the start of every
+ // navigation alongside Matched.
+ internal BrouterErrorContext? CurrentError { get; set; }
+
+ private BrouterRouteRenderer? _renderer;
+
+ protected override void OnInitialized()
+ {
+ base.OnInitialized();
+
+ if (Brouter is null)
+ throw new InvalidOperationException("A Route must be nested inside a Brouter.");
+
+ if (Group && string.IsNullOrWhiteSpace(Path) is false)
+ throw new InvalidOperationException(
+ "A Group route must not declare a Path: it contributes no URL segments. " +
+ "Put the path on its child routes (or remove Group).");
+
+ if (Group is false && Parent is null && string.IsNullOrWhiteSpace(Path))
+ throw new InvalidOperationException("A root-level Route must have a non-empty Path. " +
+ "Only nested (child) routes may use an empty path to act as an index route, and " +
+ "pathless grouping requires the Group flag.");
+
+ // Compute and parse the template (and build the renderer) before registering with the
+ // Brouter or attaching to the Parent. If parsing throws we don't want this Route to be
+ // left half-initialized in the parent/router collections.
+ if (Parent is null || string.IsNullOrWhiteSpace(Parent.FullTemplate))
+ {
+ FullTemplate = Path.Trim('/');
+ }
+ else if (string.IsNullOrEmpty(Path.Trim('/')))
+ {
+ // Index route (empty/slashes-only Path): inherit the parent's template without a trailing slash
+ // so "parent/" doesn't leak into matching/specificity calculations.
+ FullTemplate = Parent.FullTemplate.TrimEnd('/');
+ }
+ else
+ {
+ FullTemplate = $"{Parent.FullTemplate.TrimEnd('/')}/{Path.TrimStart('/')}";
+ }
+
+ // Resolve constraints against this Brouter's DI-container-scoped registry (custom constraints
+ // registered via BrouterOptions.Constraints), falling back to the built-in constraints only.
+ // See BrouterConstraintRegistry.Create (custom-then-built-in) and BrouterTemplateParser.ParseTemplate.
+ // Brouter is non-null here (checked above).
+ RouteTemplate = BrouterTemplateParser.ParseTemplate(FullTemplate, Brouter.Options.Constraints);
+
+ // 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
+ // matching loop and the winner-selection in Brouter.ProcessNavigationAsync can read
+ // them as plain field accesses instead of recomputing on every navigation.
+ var specificity = 0;
+ foreach (var seg in RouteTemplate.TemplateSegments) specificity += seg.Specificity;
+ Specificity = specificity;
+
+ // Group ancestors are invisible in the URL, so they must be invisible to the depth
+ // tiebreak too - otherwise wrapping a route in a group would silently change how it
+ // wins/loses ties against an identically-templated sibling.
+ var depth = 0;
+ for (var p = Parent; p is not null; p = p.Parent)
+ {
+ if (p.Group is false) depth++;
+ }
+ Depth = depth;
+
+ IsIndex = Group is false && 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(StringComparer.OrdinalIgnoreCase);
+ foreach (var seg in RouteTemplate.TemplateSegments)
+ {
+ if (seg.IsParameter) templateParamNames.Add(seg.Value);
+ }
+ TemplateParameterNames = templateParamNames;
+
+ _renderer = new BrouterRouteRenderer(this);
+
+ Brouter.RegisterRoute(this);
+ Parent?.AddChild(this);
+ }
+
+ /// The combined specificity score of this route's full template.
+ ///
+ /// Cached at construction. RouteTemplate / parent chain are assigned in OnInitialized
+ /// and don't change after registration, so the score never needs to be recomputed.
+ /// Recomputing per navigation showed up as a hot loop on apps with many routes.
+ ///
+ internal int Specificity { get; private set; }
+
+ /// Nesting depth (root routes are 0, each level of nesting adds 1).
+ /// Cached at construction. See .
+ internal int Depth { get; private set; }
+
+ /// True for nested index routes (child routes whose is empty or contains only slashes).
+ /// Cached at construction. See .
+ internal bool IsIndex { get; private set; }
+
+ ///
+ /// The parameter names declared in this route's template (case-insensitive). Cached at construction
+ /// and consumed by the conventional by-name component binding ().
+ ///
+ internal IReadOnlySet? TemplateParameterNames { get; private set; }
+
+
+ internal bool Matched { get; set; }
+
+ // True once this route has been matched at least once; with KeepAlive it gates "there is
+ // content worth keeping mounted while unmatched".
+ internal bool HasEverMatched { get; private set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ base.BuildRenderTree(builder);
+ _renderer?.BuildRenderTree(builder, Matched);
+ }
+
+ internal void SetMatched()
+ {
+ HasEverMatched = true;
+ // Mark the whole ancestor chain matched, then issue a single render request at the
+ // topmost node. Re-rendering the top of the chain re-renders every descendant Broute:
+ // a Broute is always declared inside some render region of its parent (that's how the
+ // ParentRoute cascade reaches it), and its render-relevant parameters (Content /
+ // ChildContent fragments, the Component Type) are reference types Blazor's change
+ // detection always treats as maybe-changed, so the subtree diff descends through every
+ // level - the same mechanism the pipeline's final StateHasChanged relies on to unrender
+ // the routes that lost the match. Calling StateHasChanged per ancestor (as this used
+ // to) queued one redundant render request per level of nesting for work the root's
+ // single request already covers.
+ Matched = true;
+
+ if (Parent is null)
+ {
+ StateHasChanged();
+ }
+ else
+ {
+ Parent.SetMatched();
+ }
+ }
+
+ // The resolved retained-instance budget: per-route KeepAliveMax, else the global default.
+ // Clamped to >= 1 so a misconfigured 0/negative value degrades to the singleton behavior
+ // instead of rendering nothing.
+ internal int EffectiveKeepAliveMax => Math.Max(1, KeepAliveMax ?? Brouter?.Options.DefaultKeepAliveMax ?? 1);
+
+ ///
+ /// Builds the retention key for the current match of a per-parameter keep-alive route
+ /// ( > 1): the route's template parameter values, in
+ /// template order, formatted invariantly. In singleton mode the key is constant (empty) so every
+ /// match reuses the one retained instance - the pre-existing re-binding behavior. The query
+ /// string is deliberately excluded (documented on ).
+ ///
+ internal string ComputeKeepAliveKey()
+ {
+ if (EffectiveKeepAliveMax <= 1 || RouteTemplate is null) return string.Empty;
+
+ var sb = new System.Text.StringBuilder();
+ foreach (var seg in RouteTemplate.TemplateSegments)
+ {
+ if (seg.IsParameter is false) continue;
+ Parameters.TryGetValue(seg.Value, out var value);
+ // U+001F (unit separator) can't appear in a template's parameter name, so the
+ // key is unambiguous even when values themselves contain '=' or '/'.
+ sb.Append(seg.Value).Append('=').Append(BrouterService.FormatRouteValue(value)).Append('\u001f');
+ }
+ return sb.ToString();
+ }
+
+ // Releases this route's retained keep-alive state - both its own inline hidden content (when it
+ // is a kept-but-hidden top-level/inline route, or hidden per-parameter siblings of the active
+ // instance) and any kept children held by the outlets it hosts. The currently active instance is
+ // left untouched. Backs IBrouter.ClearKeepAlive.
+ internal void ClearKeepAlive()
+ {
+ if (_renderer is not null && KeepAlive && HasEverMatched)
+ {
+ _renderer.DropKeptContent(Matched);
+ StateHasChanged();
+ }
+
+ foreach (var outlet in Outlets.Values)
+ {
+ outlet.ClearKeepAlive();
+ }
+ }
+
+ internal async ValueTask InvokeGuardsAsync(BrouterNavigationContext ctx)
+ {
+ // Walk from root to leaf so parents authorize children, mirroring Angular's hierarchical guards.
+ var chain = new List();
+ for (var r = this; r is not null; r = r.Parent) chain.Add(r);
+ chain.Reverse();
+
+ // No ConfigureAwait(false): guards typically touch UI state (redirect/cancel via ctx,
+ // injected services that expect the renderer context), and the navigation pipeline
+ // continues with component state mutations after we return.
+ // Observe ctx.CancellationToken at every yield point so a superseded navigation
+ // (the pipeline cancels its CTS when a newer one starts) doesn't keep running guards
+ // that may perform expensive auth/IO calls or mutate state on behalf of a stale URL.
+ if (ctx.CancellationToken.IsCancellationRequested) return false;
+
+ foreach (var node in chain)
+ {
+ if (node.Guard is not null)
+ {
+ if (ctx.CancellationToken.IsCancellationRequested) return false;
+ await node.Guard(ctx);
+ if (ctx.CancellationToken.IsCancellationRequested) return false;
+ if (ctx.IsCancelled || ctx.RedirectUrl is not null) return false;
+ }
+ }
+
+ return true;
+ }
+
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ Brouter?.UnregisterRoute(this);
+ Parent?.RemoveChild(this);
+
+ // Drop any kept-alive render entry the parent's outlets hold for this route, so a disposed
+ // (conditionally removed) route can't linger as hidden content.
+ if (Parent is not null)
+ {
+ foreach (var outlet in Parent.Outlets.Values)
+ {
+ outlet.ForgetChild(this);
+ }
+ }
+ }
+
+ private bool _disposed;
+}
diff --git a/src/Brouter/Bit.Brouter/BroutePrerenderState.cs b/src/Brouter/Bit.Brouter/BroutePrerenderState.cs
new file mode 100644
index 0000000000..a6ab79f515
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BroutePrerenderState.cs
@@ -0,0 +1,113 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+
+namespace Bit.Brouter;
+
+///
+/// The serialized form of a single loader result carried across the SSR/prerender -> 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 ),
+/// which is what components consuming the cascading RouteData expect.
+///
+internal sealed class PersistedLoaderState
+{
+ /// Assembly-qualified name of the loaded value's runtime type. Null when the loader returned null.
+ public string? TypeName { get; set; }
+
+ /// The loaded value serialized as JSON. Null when the loader returned null.
+ public string? Json { get; set; }
+}
+
+///
+/// Bridges route results across the prerender -> 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 and takes responsibility
+/// for keeping their loader data types serializable and preserved.
+///
+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);
+
+ ///
+ /// 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.
+ ///
+ internal static string MakeKey(string path, string query, int chainIndex) =>
+ $"Bit.Brouter|{path}|{query}|{chainIndex}";
+
+ ///
+ /// Captures a loader result into its persistable form using
+ /// (a source-generated-resolver-backed instance for AOT-safety, see
+ /// ) or the reflection-based defaults.
+ /// Returns null when the value can't be serialized (e.g. the supplied resolver doesn't
+ /// cover its type) - the caller then skips persisting it and the loader simply re-runs on the
+ /// interactive pass, so an unserializable type never breaks prerender.
+ ///
+ [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, JsonSerializerOptions? serializerOptions = null)
+ {
+ if (value is null) return new PersistedLoaderState { TypeName = null, Json = null };
+
+ var type = value.GetType();
+ try
+ {
+ return new PersistedLoaderState
+ {
+ TypeName = type.AssemblyQualifiedName,
+ Json = JsonSerializer.Serialize(value, type, serializerOptions ?? _options),
+ };
+ }
+ catch (Exception ex) when (ex is NotSupportedException or InvalidOperationException or JsonException)
+ {
+ // Unserializable under the active resolver/options: skip persistence for this entry.
+ return null;
+ }
+ }
+
+ ///
+ /// Rehydrates a previously-captured loader result. Returns true when a value (possibly null)
+ /// was restored and the loader should be skipped; false when restoration wasn't possible
+ /// (unknown type, malformed JSON) and the loader should run normally.
+ ///
+ [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, JsonSerializerOptions? serializerOptions = null)
+ {
+ 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;
+
+ try
+ {
+ // Type.GetType(throwOnError: false) suppresses TypeLoadException (returns null) but can still
+ // throw for a stale/unloadable persisted name - a malformed assembly-qualified string
+ // (ArgumentException), or a referenced assembly that can't be loaded here
+ // (FileLoadException / FileNotFoundException / BadImageFormatException). Treat all of those,
+ // like a missing type or malformed JSON, as "can't restore" and fall back to running the loader.
+ var type = Type.GetType(state.TypeName, throwOnError: false);
+ if (type is null) return false; // type not available here; fall back to running the loader
+
+ value = JsonSerializer.Deserialize(state.Json, type, serializerOptions ?? _options);
+ return true;
+ }
+ catch (Exception ex) when (ex is JsonException
+ or ArgumentException
+ or NotSupportedException
+ or InvalidOperationException
+ or System.IO.FileLoadException
+ or System.IO.FileNotFoundException
+ or BadImageFormatException
+ or TypeLoadException)
+ {
+ value = null;
+ return false;
+ }
+ }
+}
diff --git a/src/Brouter/Bit.Brouter/BrouteScanner.cs b/src/Brouter/Bit.Brouter/BrouteScanner.cs
new file mode 100644
index 0000000000..015457807c
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouteScanner.cs
@@ -0,0 +1,117 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+
+namespace Bit.Brouter;
+
+///
+/// Discovers attribute-routed components (@page / [Route]) 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
+/// tree, mirroring the built-in Router.AppAssembly / AdditionalAssemblies model (including
+/// lazily-loaded assemblies added at runtime).
+///
+internal static class BrouteScanner
+{
+ /// A single route discovered from a [Route] attribute on a routable component.
+ internal readonly record struct DiscoveredRoute(
+ string Template,
+ [property: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type ComponentType);
+
+ ///
+ /// Scans and for public or
+ /// internal component types annotated with one or more and returns the
+ /// discovered routes. Each [Route] on a component yields one entry, so a component with several
+ /// route attributes contributes several routes. Duplicate assemblies are scanned only once.
+ ///
+ ///
+ /// 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.
+ ///
+ [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 Discover(Assembly? appAssembly, IReadOnlyList? 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();
+ var results = new List();
+
+ 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 seen, List 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));
+ }
+ }
+ }
+
+ ///
+ /// 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 {*rest} while
+ /// Brouter's parser expects the double-star form {**rest}. Everything else (literals,
+ /// {id:int} constraints, optional {id?} parameters) is already compatible.
+ ///
+ 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);
+ }
+}
diff --git a/src/Brouter/Bit.Brouter/Brouter.cs b/src/Brouter/Bit.Brouter/Brouter.cs
index aec85fcffb..e898721dae 100644
--- a/src/Brouter/Bit.Brouter/Brouter.cs
+++ b/src/Brouter/Bit.Brouter/Brouter.cs
@@ -1,13 +1,17 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
namespace Bit.Brouter;
///
-/// The root component of Bit.Brouter. Hosts a tree of children and renders
+/// The root component of Bit.Brouter. Hosts a tree of children and renders
/// the matching one for the current URL.
///
public class Brouter : ComponentBase, IDisposable, IAsyncDisposable
@@ -27,54 +31,273 @@ public class Brouter : ComponentBase, IDisposable, IAsyncDisposable
/// Inline content to render when no route matches and is null.
[Parameter] public RenderFragment? NotFoundContent { get; set; }
+ ///
+ /// Router-level error UI: the fallback boundary for commit-phase navigation failures (typically
+ /// a route throwing) when no route in the failed chain declares its
+ /// own . Rendered in place of the routed content with a
+ /// (exception, location, RetryAsync()). When neither
+ /// this nor any route boundary exists, failures keep the previous behavior: the old page stays
+ /// visible and only the hook observes the error.
+ ///
+ [Parameter] public RenderFragment? ErrorContent { get; set; }
+
+ ///
+ /// Optional "pending navigation" UI, shown while a navigation is awaiting its route
+ /// s. Mirrors the built-in Router.Navigating: when a matched
+ /// route (or one of its ancestors in the matched chain) has a slow loader, this fragment is
+ /// rendered in place of the routed content until the loaders finish, then the matched route is
+ /// revealed. It is shown lazily - only once a loader is actually about to run - so navigations
+ /// with no loaders (or whose loader results were restored from prerender state) never flash it,
+ /// and instant navigations don't flicker. Left null (the default), the previous page simply
+ /// stays visible until the new route is ready.
+ ///
+ [Parameter] public RenderFragment? Navigating { get; set; }
+
+ ///
+ /// When true, the s of a matched route chain run concurrently
+ /// instead of one at a time. By default loaders run sequentially root -> leaf (mirroring
+ /// guard order), so a child's loader can rely on work its parent's loader completed (e.g.
+ /// state stashed in a scoped service) - but the total wait is the sum of every loader in
+ /// the chain. When the loaders are independent (the common case), enable this to start
+ /// them together so the wait is only as long as the slowest one, like React Router.
+ /// Results are still committed and errors still surfaced in root -> leaf order, so render
+ /// and failure behavior are unchanged; only the awaiting overlaps. Leave this off if any
+ /// child loader depends on its parent's loader having finished.
+ ///
+ [Parameter] public bool ParallelLoaders { get; set; }
+
/// Async hook fired whenever a route is successfully matched.
- [Parameter] public Func? OnMatch { get; set; }
+ [Parameter] public Func? OnMatch { get; set; }
/// Async hook fired when no route matches the current URL.
[Parameter] public Func? OnNotFound { get; set; }
+ ///
+ /// The assembly to scan for attribute-routed components (@page / [Route]). Discovered
+ /// routes are matched alongside any hand-declared children, so pages can live
+ /// colocated with their route templates instead of being enumerated in one tree. Mirrors
+ /// Router.AppAssembly. When null, no assembly scanning happens.
+ ///
+ [Parameter] public Assembly? AppAssembly { get; set; }
+
+ ///
+ /// Additional assemblies to scan for attribute-routed components, e.g. Razor class libraries or
+ /// lazily-loaded assemblies. Add to this collection (with a new instance/re-render) as assemblies are
+ /// loaded to register their routes at runtime. Mirrors Router.AdditionalAssemblies.
+ ///
+ [Parameter] public IEnumerable? AdditionalAssemblies { get; set; }
+
+ ///
+ /// Async per-navigation hook that runs before route matching, for loading route assemblies on
+ /// demand (the Brouter counterpart of the built-in Router.OnNavigateAsync +
+ /// LazyAssemblyLoader pattern): inspect ctx.To, load what the target needs (e.g.
+ /// LazyAssemblyLoader.LoadAssembliesAsync on WebAssembly), and return the loaded
+ /// assemblies - their @page/[Route] components are scanned and registered
+ /// within the same navigation, so the URL being navigated to can match a page whose
+ /// assembly loaded moments ago. Return null (or already-known assemblies) for no-op. Runs in
+ /// the preventive phase (a slow load keeps the current page visible; combine with
+ /// for pending UI); observe ctx.CancellationToken for
+ /// supersession. A thrown exception fails the navigation closed and surfaces via OnError.
+ ///
+ [Parameter] public Func?>>? OnNavigateAsync { get; set; }
+
[Inject] private NavigationManager _navManager { get; set; } = default!;
[Inject] private INavigationInterception _navInterception { get; set; } = default!;
[Inject] private BrouterService _brouterService { get; set; } = default!;
+ [Inject] private IServiceProvider _services { get; set; } = default!;
internal BrouterLocation CurrentLocation { get; private set; } = BrouterLocation.Empty;
internal BrouterOptions Options => _brouterService.Options;
- private readonly List _routes = [];
+
+ // Routes discovered by scanning AppAssembly / AdditionalAssemblies for [Route]/@page components.
+ // Rendered as synthetic children in BuildRenderTree so they reuse the whole matching /
+ // guard / loader / render pipeline. Recomputed only when the assembly set actually changes.
+ private IReadOnlyList _discoveredRoutes = [];
+ private Assembly? _lastAppAssembly;
+ private Assembly[]? _lastAdditionalAssemblies;
+ private bool _discoveryComputed;
+
+ // Assemblies handed back by the OnNavigateAsync hook at runtime (lazy loading). Merged into
+ // every discovery scan alongside AppAssembly/AdditionalAssemblies; grows monotonically.
+ private readonly List _runtimeAssemblies = [];
+
+ // Prerender -> interactive loader-state bridge (see BroutePrerenderState). Only active when
+ // Options.PersistLoaderState is set and a PersistentComponentState is available in the scope.
+ // _loaderStateJsonOptions is non-null only when the consumer supplied a source-generated
+ // resolver (Options.LoaderStateTypeInfoResolver) for trimming/AOT-safe serialization.
+ private System.Text.Json.JsonSerializerOptions? _loaderStateJsonOptions;
+ private PersistentComponentState? _persistentState;
+ private PersistingComponentStateSubscription _persistSubscription;
+ private bool _persistSubscribed;
+ // Loader results staged during the current navigation's commit, keyed by their persistence key.
+ // Serialized by the RegisterOnPersisting callback at the end of prerender.
+ private readonly Dictionary _loaderStateToPersist = new(StringComparer.Ordinal);
+
+ private readonly List _routes = [];
+ // Names of the currently-registered routes, for O(1) uniqueness enforcement in RegisterRoute
+ // (a linear scan there made startup O(n^2)). Case-insensitive to match FindRouteByName's lookup
+ // comparison, so a name that collides on lookup also collides on registration. Kept in lockstep
+ // with _routes: every named route added/removed updates this set on the same single dispatcher,
+ // so no synchronization is needed (see the threading note above).
+ private readonly HashSet _routeNames = new(StringComparer.OrdinalIgnoreCase);
+ // Canonical-template -> registered holders, for O(1) ambiguity detection in RegisterRoute. Two
+ // routes map to the same key exactly when winner selection could only ever tell them apart by
+ // registration order (see BuildTemplateCollisionKey), which is a silent coin-flip we refuse
+ // instead - mirroring the built-in router's AmbiguousMatchException for duplicate templates.
+ // The one sanctioned cohabitation is a hand-declared route shadowing an attribute-discovered
+ // one (the documented override pattern; the hand-declared route registers first and wins the
+ // order tie), so a key holds at most one route of each kind - the list never exceeds two.
+ // Ordinal comparer: all case normalization is baked into the key itself so a CaseSensitive
+ // flip can't corrupt lookups.
+ private readonly Dictionary> _routesByTemplateKey = new(StringComparer.Ordinal);
// Snapshot of _routes refreshed lazily after Register/Unregister. The matching loop
// iterates this snapshot so we don't allocate a fresh array on every navigation.
- // Volatile read/write keeps the snapshot publication ordered relative to the dirty
- // flag (we only ever flip _routesDirty -> true under the same dispatcher that calls
- // Register/Unregister, but a navigation pipeline awaiting back can re-enter on the
- // dispatcher and observe a stale snapshot if not for the volatile read/write pair).
- private BrouterRoute[] _routesSnapshot = [];
+ //
+ // These fields are deliberately plain (not volatile) and accessed without any
+ // synchronization: safety comes entirely from every reader and writer running on the
+ // renderer's single-threaded dispatcher. RegisterRoute/UnregisterRoute are called from
+ // Broute component lifecycle (OnInitialized/Dispose), and GetRoutesSnapshot/SelectWinner/
+ // FindRouteByName run inside the navigation pipeline (ProcessNavigationAsync / the
+ // changing handler) - all on that same dispatcher. There is therefore no cross-thread
+ // publication to order, so no volatile/Interlocked is needed. If any of these were ever
+ // called off-dispatcher this reasoning breaks and the access would need real synchronization.
+ private Broute[] _routesSnapshot = [];
private bool _routesDirty = true;
- internal void RegisterRoute(BrouterRoute route)
+ internal void RegisterRoute(Broute route)
{
- // Enforce the documented uniqueness contract for Route.Name. Comparison matches
- // FindRouteByName (case-insensitive), so name lookups stay unambiguous.
- if (string.IsNullOrEmpty(route.Name) is false)
+ // Reject templates that would be ambiguous with an already-registered route. A hand-declared /
+ // attribute-discovered pair is exempt: that's the documented override pattern (the hand-declared
+ // route wins the order tie), not a duplication bug. This is a pure lookup (nothing is mutated
+ // before a throw), so a rejected route leaves every set untouched.
+ string? templateKey = null;
+ List? templateHolders = null;
+ // Group routes never match by themselves (GetRouteIndex excludes them), so they can't be
+ // ambiguous with anything - two sibling groups sharing a template is the normal case.
+ if (route.Group is false && route.RouteTemplate is not null)
{
- for (int i = 0; i < _routes.Count; i++)
+ templateKey = BuildTemplateCollisionKey(route);
+ if (_routesByTemplateKey.TryGetValue(templateKey, out templateHolders))
{
- var existing = _routes[i];
- if (ReferenceEquals(existing, route)) continue;
- if (string.Equals(existing.Name, route.Name, StringComparison.OrdinalIgnoreCase))
+ foreach (var existing in templateHolders)
{
+ if (existing.IsDiscovered != route.IsDiscovered) continue;
+
+ var existingDescription = existing.Component is null
+ ? $"'{existing.FullTemplate}'"
+ : $"'{existing.FullTemplate}' (component '{existing.Component.FullName}')";
throw new InvalidOperationException(
- $"A route with the name '{route.Name}' is already registered. Route names must be unique (case-insensitive).");
+ $"The route template '{route.FullTemplate}' is ambiguous with the already registered template " +
+ $"{existingDescription}: both match exactly the same URLs, so the winner would be decided " +
+ "by registration order alone. Remove or change one of the routes. Note that parameter names " +
+ "(and, under case-insensitive matching, letter casing) do not distinguish templates. If you are " +
+ "swapping two same-template s in a single render (e.g. an @if/else), the new one " +
+ "initializes before the old one is disposed - keep a single rendered and vary its " +
+ "parameters instead.");
}
}
}
+ // Enforce the documented uniqueness contract for Route.Name. The name set uses the same
+ // case-insensitive comparison as FindRouteByName, so name lookups stay unambiguous. Adding to
+ // the set is the uniqueness check: a failed Add means an equal name is already registered.
+ // O(1) per route, keeping startup registration linear rather than O(n^2).
+ var named = string.IsNullOrEmpty(route.Name) is false;
+ if (named && _routeNames.Add(route.Name!) is false)
+ {
+ throw new InvalidOperationException(
+ $"A route with the name '{route.Name}' is already registered. Route names must be unique (case-insensitive).");
+ }
+
+ if (templateKey is not null)
+ {
+ if (templateHolders is null)
+ {
+ templateHolders = [];
+ _routesByTemplateKey.Add(templateKey, templateHolders);
+ }
+ templateHolders.Add(route);
+ route.TemplateCollisionKey = templateKey;
+ }
_routes.Add(route);
_routesDirty = true;
}
- internal void UnregisterRoute(BrouterRoute route)
+ internal void UnregisterRoute(Broute route)
{
- if (_routes.Remove(route)) _routesDirty = true;
+ if (_routes.Remove(route) is false) return;
+ // Prune the committed-chain record so leave guards never run against a disposed route
+ // (e.g. a conditionally-rendered that was removed while its content was shown).
+ if (Array.IndexOf(_committedChain, route) >= 0)
+ {
+ _committedChain = _committedChain.Where(r => ReferenceEquals(r, route) is false).ToArray();
+ }
+ // Keep the name and template sets in lockstep so the freed name/template can be re-registered
+ // later. The template key is removed by the exact string it was registered under, not
+ // recomputed, so an Options.CaseSensitive flip in between can't strand a stale entry.
+ if (string.IsNullOrEmpty(route.Name) is false) _routeNames.Remove(route.Name);
+ if (route.TemplateCollisionKey is not null)
+ {
+ if (_routesByTemplateKey.TryGetValue(route.TemplateCollisionKey, out var holders))
+ {
+ holders.Remove(route);
+ if (holders.Count == 0) _routesByTemplateKey.Remove(route.TemplateCollisionKey);
+ }
+ route.TemplateCollisionKey = null;
+ }
+ _routesDirty = true;
+ }
+
+ ///
+ /// Builds the canonical identity of a route's matching behavior, used to detect ambiguous
+ /// registrations. Two routes get the same key exactly when
+ /// could only ever break a tie between them by registration order: they match the same URLs
+ /// with the same specificity, and share the depth / index-route tiebreak inputs.
+ ///
+ ///
+ /// Deliberate normalizations, matching what actually distinguishes:
+ /// parameter names are dropped ("/users/{id}" and "/users/{userId}" match identically), the
+ /// literal catch-all "**" and a catch-all parameter "{**rest}" unify (a catch-all's optional
+ /// flag is also irrelevant - it already matches zero segments), literal casing folds when
+ /// matching is case-insensitive, and constraint tokens fold case to mirror the case-insensitive
+ /// constraint registry. Depth and index-ness are part of the key because identical templates at
+ /// different depths (a parent and its index child) are resolved deterministically by the
+ /// documented depth/index tiebreaks - only a full tie is ambiguous. Constraint order is kept:
+ /// the last constraint's conversion wins, so reordered constraints are behaviorally distinct.
+ ///
+ private string BuildTemplateCollisionKey(Broute route)
+ {
+ var sb = new StringBuilder();
+ sb.Append(route.Depth).Append(route.IsIndex ? "i|" : "-|");
+
+ var caseSensitive = Options.CaseSensitive;
+ foreach (var seg in route.RouteTemplate!.TemplateSegments)
+ {
+ sb.Append('/');
+ if (seg.IsCatchAll)
+ {
+ sb.Append("**");
+ }
+ else if (seg.IsParameter)
+ {
+ sb.Append('{');
+ for (var i = 0; i < seg.Constraints.Length; i++)
+ {
+ if (i > 0) sb.Append(':');
+ sb.Append(seg.Constraints[i].Name.ToLowerInvariant());
+ }
+ if (seg.IsOptional) sb.Append('?');
+ sb.Append('}');
+ }
+ else
+ {
+ // Covers plain literals and the single-segment wildcard "*" (its Value is "*", which
+ // can't collide with a real literal: TemplateParser never produces a literal "*").
+ sb.Append(caseSensitive ? seg.Value : seg.Value.ToLowerInvariant());
+ }
+ }
+ return sb.ToString();
}
///
@@ -87,7 +310,7 @@ internal void UnregisterRoute(BrouterRoute route)
/// underlying List itself out so a caller can't accidentally mutate the registration
/// set mid-pipeline.
///
- private BrouterRoute[] GetRoutesSnapshot()
+ private Broute[] GetRoutesSnapshot()
{
if (_routesDirty is false) return _routesSnapshot;
var arr = _routes.ToArray();
@@ -96,21 +319,268 @@ private BrouterRoute[] GetRoutesSnapshot()
return arr;
}
- internal BrouterRoute? FindRouteByName(string name) =>
- _routes.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
+ // First-segment matching index, derived lazily from the route snapshot. Keeps navigation off the
+ // O(routes) full scan: SelectWinner only has to try routes whose first template segment can match
+ // the URL's first segment, plus the (usually small) set of routes that start with a parameter /
+ // wildcard / catch-all / empty template. Rebuilt only when the snapshot reference changes (routes
+ // registered/unregistered) or the case-sensitivity option flips, since the literal buckets key on
+ // it. See GetRouteIndex.
+ private RouteIndex? _routeIndex;
+ private Broute[]? _indexedSnapshot;
+ private bool _indexedCaseSensitive;
+
+ // A route paired with its registration order (index in the snapshot). Order is carried alongside
+ // the route so winner selection can break exact specificity/depth/index ties by earliest
+ // declaration - reproducing the old "keep the first candidate on a tie" behavior even though the
+ // index visits routes bucket-first rather than in pure registration order.
+ private readonly struct RouteEntry
+ {
+ public Broute Route { get; }
+ public int Order { get; }
+ public RouteEntry(Broute route, int order)
+ {
+ Route = route;
+ Order = order;
+ }
+ }
+
+ // The precomputed first-segment index. LiteralBuckets maps a first literal segment (compared with
+ // the same comparer the matcher uses for literals) to the routes that start with it. NonLiteralFirst
+ // holds routes whose first segment isn't a fixed literal (parameter / '*' / '**' / catch-all) or
+ // whose template is empty - all of which can match regardless of the URL's first segment, so they're
+ // always considered.
+ private sealed class RouteIndex
+ {
+ public Dictionary> LiteralBuckets { get; }
+ public List NonLiteralFirst { get; }
+ public RouteIndex(Dictionary> literalBuckets, List nonLiteralFirst)
+ {
+ LiteralBuckets = literalBuckets;
+ NonLiteralFirst = nonLiteralFirst;
+ }
+ }
+
+ ///
+ /// Returns the first-segment matching index for the current route snapshot, rebuilding it only
+ /// when the snapshot reference changes (a route registered/unregistered) or the case-sensitivity
+ /// option flips. Shares the snapshot with so the two never disagree
+ /// about the route set, and runs on the same single dispatcher as every other reader (no locking).
+ ///
+ private RouteIndex GetRouteIndex()
+ {
+ var snapshot = GetRoutesSnapshot();
+ var caseSensitive = Options.CaseSensitive;
+ if (_routeIndex is not null &&
+ ReferenceEquals(_indexedSnapshot, snapshot) &&
+ _indexedCaseSensitive == caseSensitive)
+ {
+ return _routeIndex;
+ }
+
+ // Literal buckets key on the first segment using the same comparer the matcher applies to
+ // literal segments, so a lookup by the URL's first segment returns exactly the routes whose
+ // first literal would match it.
+ var comparer = caseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
+ var literalBuckets = new Dictionary>(comparer);
+ var nonLiteralFirst = new List();
+
+ for (int i = 0; i < snapshot.Length; i++)
+ {
+ // Pathless grouping routes stay registered (their Matched flag must be reset each
+ // navigation with everyone else's) but never participate in matching: they win or lose
+ // with their children, via the parent chain.
+ if (snapshot[i].Group) continue;
+
+ var entry = new RouteEntry(snapshot[i], i);
+ var template = snapshot[i].RouteTemplate;
+
+ // No template (not yet initialized) or an empty template (root/index) can't be bucketed by
+ // a first literal - always consider it. TryMatch still filters it correctly.
+ if (template is null || template.TemplateSegments.Count == 0)
+ {
+ nonLiteralFirst.Add(entry);
+ continue;
+ }
+
+ var first = template.TemplateSegments[0];
+ if (first.IsParameter || first.IsCatchAll || first.IsSingleWildcard)
+ {
+ // Parameter / '*' / '**' / '{**catch}' first segment matches many URL first segments.
+ nonLiteralFirst.Add(entry);
+ }
+ else
+ {
+ if (literalBuckets.TryGetValue(first.Value, out var list) is false)
+ {
+ list = [];
+ literalBuckets[first.Value] = list;
+ }
+ list.Add(entry);
+ }
+ }
+
+ _routeIndex = new RouteIndex(literalBuckets, nonLiteralFirst);
+ _indexedSnapshot = snapshot;
+ _indexedCaseSensitive = caseSensitive;
+ return _routeIndex;
+ }
+
+ // Releases all retained keep-alive state across every registered route (their inline hidden
+ // content and any kept children held by outlets), keeping only the currently active route.
+ // Backs IBrouter.ClearKeepAlive. Runs on the renderer dispatcher like every other reader; each
+ // affected route/outlet issues its own re-render so the dropped subtrees are disposed.
+ internal void ClearKeepAlive()
+ {
+ foreach (var route in GetRoutesSnapshot()) route.ClearKeepAlive();
+ }
+
+ // Reads the snapshot, not the live List: mirrors SelectWinner/ProcessNavigationAsync so name
+ // lookups never touch the mutable registration set mid-pipeline (see GetRoutesSnapshot's remarks).
+ internal Broute? FindRouteByName(string name)
+ {
+ var routesSnapshot = GetRoutesSnapshot();
+ foreach (var r in routesSnapshot)
+ {
+ if (string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase)) return r;
+ }
+ return null;
+ }
private CancellationTokenSource? _navCts;
private bool _noRouteMatched;
private long _navVersion;
+ // The active router-level error boundary state (see RenderNavigationError). Non-null only when a
+ // commit-phase failure bubbled past every route boundary and Brouter.ErrorContent is set; rendered
+ // by BuildRenderTree in place of routed content. Cleared at the start of each navigation.
+ private BrouterErrorContext? _navError;
+
+ // The route chain (root -> leaf) whose content is currently committed to the screen. Consumed by
+ // InvokeLeaveGuardsAsync to determine which routes a pending navigation deactivates. Updated at
+ // every commit outcome: the matched chain on success, the chain down to the boundary on an error
+ // render, and empty on a not-found render. Same single-dispatcher discipline as the other
+ // navigation-state fields above.
+ private Broute[] _committedChain = [];
+
+ // Awaited-navigation bookkeeping (IBrouter.NavigateAsync). At most one navigation outcome is
+ // pending at a time: registering a new one supersedes the old (that IS the Superseded outcome).
+ // Resolution is keyed on the target's absolute URI so a pipeline for some other navigation can
+ // never resolve an awaiter that wasn't asking about it. Same single-dispatcher discipline as
+ // the other navigation-state fields.
+ private TaskCompletionSource? _pendingOutcome;
+ private string? _pendingOutcomeUri;
+
+ ///
+ /// Registers an awaiter for the navigation about to be triggered toward .
+ /// The returned task resolves when the pipeline concludes that navigation (see BrouterNavigationOutcome).
+ ///
+ internal Task RegisterNavigationOutcome(string absoluteUri)
+ {
+ _pendingOutcome?.TrySetResult(BrouterNavigationOutcome.Superseded());
+ // RunContinuationsAsynchronously: resolvers run inside the navigation pipeline on the
+ // renderer dispatcher; awaiter continuations must not run inline there.
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pendingOutcome = tcs;
+ _pendingOutcomeUri = absoluteUri;
+ return tcs.Task;
+ }
+
+ // Resolves the pending awaiter when (and only when) it was registered for exactly this target.
+ private void ResolveNavigationOutcome(string targetUri, BrouterNavigationOutcome outcome)
+ {
+ var pending = _pendingOutcome;
+ if (pending is null || string.Equals(_pendingOutcomeUri, targetUri, StringComparison.Ordinal) is false) return;
+ _pendingOutcome = null;
+ _pendingOutcomeUri = null;
+ pending.TrySetResult(outcome);
+ }
+
+ // True only while the current navigation is awaiting a route loader and a Navigating fragment is
+ // set. Drives the pending-navigation UI emitted by BuildRenderTree. Set and cleared exclusively on
+ // the renderer's single-threaded dispatcher inside the navigation pipeline (see the pending-UI
+ // handling in ProcessNavigationAsync), so - like the other plain nav-state fields above - it needs
+ // no synchronization.
+ private bool _navigating;
+
+ // Registration returned by NavigationManager.RegisterLocationChangingHandler. Disposed on
+ // teardown to unhook the preventive guard/redirect/cancel decision (see OnLocationChanging).
+ private IDisposable? _locationChangingRegistration;
+
+ // True when the latest committed navigation started a View Transition whose completion is owed
+ // after its render lands. Consumed by OnAfterRenderAsync (see the view-transition handshake in
+ // ProcessNavigationAsync). Same single-dispatcher discipline as the surrounding fields.
+ private bool _pendingViewTransitionCompletion;
+
+ // Location whose post-navigation DOM effects (fragment/top scroll, focus) are pending. Staged
+ // by ProcessNavigationAsync on a successful commit and consumed by OnAfterRenderAsync after the
+ // matching render lands, so fragment/focus selectors resolve against the new route's DOM. Only
+ // the most recent commit is held; a later navigation overwrites an unconsumed value so we never
+ // apply effects for a page the user has already navigated away from. Staged and consumed on the
+ // renderer dispatcher; accessed via Interlocked.Exchange for a clean read-and-clear (mirrors the
+ // plain-field + Interlocked style used for _navCts rather than the `volatile` keyword).
+ private BrouterLocation? _pendingEffectsLocation;
+
+ // Hand-off from the preventive "changing" phase to the "changed" (commit) phase. When the
+ // LocationChanging handler has already run OnNavigating + guards for a target and approved it,
+ // it records the target's absolute URI here so the subsequent LocationChanged commit phase can
+ // skip re-running those side-effecting hooks (they must run exactly once per navigation).
+ // Read-and-cleared by the commit phase; overwritten by each new approved decision.
+ private string? _approvedTargetUri;
+
+ // Navigation-type bookkeeping (see BrouterNavigationType). _pendingNavigationType is stamped by
+ // Brouter's own programmatic navigations (BrouterService.Navigate and the internal redirect/restore
+ // calls, all funnelled through NavigateInternal / SetPendingNavigationType) right before they call
+ // NavigationManager.NavigateTo, so the phase that observes the resulting navigation knows it was a
+ // push vs a replace rather than having to guess. It is consumed exactly once - by whichever of the
+ // changing or commit phase runs first for that navigation - so it can never leak into a later one.
+ // A navigation with no pending type is either an intercepted link click (a push) or, when not
+ // intercepted, a history traversal (Back/Forward => pop). Like the other nav-state fields it is only
+ // touched on the renderer's single dispatcher, so it needs no synchronization.
+ private BrouterNavigationType? _pendingNavigationType;
+ // Hand-off of the resolved type from the preventive changing phase to the commit phase, paired with
+ // _approvedTargetUri: set only when the changing phase approves a navigation, read-and-cleared by the
+ // commit phase so it re-uses the exact type the changing phase computed instead of recomputing it.
+ private BrouterNavigationType? _approvedNavigationType;
+
protected override void OnInitialized()
{
base.OnInitialized();
+ // Compute discovered routes here (not only in OnParametersSet): the initial route match runs in
+ // OnInitializedAsync, before OnParametersSet, and the synthetic children must already be
+ // present in the first render so they register in time to be matched on the initial navigation.
+ // OnParametersSet still re-checks afterwards to pick up runtime changes to the assembly set.
+ RefreshDiscoveredRoutesIfNeeded();
+
_brouterService.Attach(this, _navManager);
+ // Wire up prerender loader-state persistence once, before the first navigation runs its loaders.
+ // PersistentComponentState is resolved optionally (GetService, not [Inject]) so Brouter still works
+ // in hosts/tests where it isn't registered (e.g. bUnit, plain WASM without prerender).
+ if (Options.PersistLoaderState && _persistSubscribed is false)
+ {
+ _persistentState = _services.GetService();
+ if (_persistentState is not null)
+ {
+ if (Options.LoaderStateTypeInfoResolver is not null)
+ {
+ _loaderStateJsonOptions = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
+ {
+ TypeInfoResolver = Options.LoaderStateTypeInfoResolver,
+ };
+ }
+ _persistSubscription = _persistentState.RegisterOnPersisting(PersistLoaderStateAsync);
+ _persistSubscribed = true;
+ }
+ }
+
_navManager.LocationChanged += NavManagerLocationChanged;
+#if NET10_0_OR_GREATER
+ // .NET 10 not-found contract: app code (or the framework) calls NavigationManager.NotFound()
+ // to signal a missing resource; routers subscribe to OnNotFound and render their fallback.
+ _navManager.OnNotFound += NavManagerOnNotFound;
+#endif
// Establish the initial location synchronously so any code that reads
// BrouterService.Location before the first navigation pipeline runs sees
@@ -118,12 +588,145 @@ protected override void OnInitialized()
CurrentLocation = ComputeLocation();
}
+ protected override void OnParametersSet()
+ {
+ base.OnParametersSet();
+
+ // Refresh discovered routes whenever the assembly set changes (including the first parameter set,
+ // and when AdditionalAssemblies grows because a lazy-loaded assembly was added). Kept out of the
+ // navigation pipeline so scanning cost is paid on parameter changes, not per navigation.
+ RefreshDiscoveredRoutesIfNeeded();
+ }
+
+ [UnconditionalSuppressMessage("Trimming", "IL2026",
+ Justification = "Attribute-route discovery is opt-in via AppAssembly/AdditionalAssemblies. The " +
+ "consumer is responsible for preserving their routable components under trimming, " +
+ "exactly as the built-in Blazor Router requires.")]
+ private void RefreshDiscoveredRoutesIfNeeded()
+ {
+ // Materialize the enumerable once: it may be a lazily-evaluated sequence, and we both compare and
+ // (potentially) hand it to the scanner.
+ var additional = AdditionalAssemblies as Assembly[] ?? AdditionalAssemblies?.ToArray();
+
+ if (_discoveryComputed &&
+ ReferenceEquals(AppAssembly, _lastAppAssembly) &&
+ SameAssemblies(_lastAdditionalAssemblies, additional))
+ {
+ return;
+ }
+
+ _lastAppAssembly = AppAssembly;
+ _lastAdditionalAssemblies = additional;
+ _discoveryComputed = true;
+
+ // Runtime-loaded assemblies (OnNavigateAsync) join the scan set transparently.
+ var scanSet = additional;
+ if (_runtimeAssemblies.Count > 0)
+ {
+ scanSet = additional is null || additional.Length == 0
+ ? _runtimeAssemblies.ToArray()
+ : additional.Concat(_runtimeAssemblies).Distinct().ToArray();
+ }
+
+ _discoveredRoutes = (AppAssembly is null && (scanSet is null || scanSet.Length == 0))
+ ? []
+ : BrouteScanner.Discover(AppAssembly, scanSet);
+ }
+
+ ///
+ /// Runs the hook for a pending navigation and, when it returns
+ /// new assemblies, folds them into route discovery and yields one render so the new synthetic
+ /// <Broute>s register - all before matching runs, so the very navigation that triggered
+ /// the load can match a freshly-loaded page.
+ ///
+ private async ValueTask RunOnNavigateHookAsync(BrouterNavigationContext ctx)
+ {
+ if (OnNavigateAsync is null) return;
+
+ var assemblies = await OnNavigateAsync(ctx);
+ if (assemblies is null) return;
+
+ var added = false;
+ foreach (var assembly in assemblies)
+ {
+ if (assembly is null || _runtimeAssemblies.Contains(assembly)) continue;
+ _runtimeAssemblies.Add(assembly);
+ added = true;
+ }
+ if (added is false) return;
+
+ // Recompute discovery with the grown runtime set, then let the renderer flush once so the
+ // new synthetic children run OnInitialized and register - the same yield-to-register
+ // technique the initial mount uses (see OnInitializedAsync).
+ _discoveryComputed = false;
+ RefreshDiscoveredRoutesIfNeeded();
+ StateHasChanged();
+ await Task.Yield();
+ }
+
+ private static bool SameAssemblies(Assembly[]? a, Assembly[]? b)
+ {
+ if (ReferenceEquals(a, b)) return true;
+ if (a is null || b is null) return false;
+ if (a.Length != b.Length) return false;
+ for (int i = 0; i < a.Length; i++)
+ {
+ if (ReferenceEquals(a[i], b[i]) is false) return false;
+ }
+ return true;
+ }
+
+ // Serializes the loader results staged during prerender into PersistentComponentState so the interactive
+ // pass can restore them instead of re-fetching. Registered via RegisterOnPersisting; fires once at the
+ // end of prerender.
+ [UnconditionalSuppressMessage("Trimming", "IL2026",
+ Justification = "Only reached when the consumer opts into Options.PersistLoaderState and accepts the " +
+ "reflection-based JSON serialization contract documented on that option.")]
+ [UnconditionalSuppressMessage("AOT", "IL3050",
+ Justification = "See above; PersistLoaderState is opt-in and documents its AOT limitations.")]
+ private Task PersistLoaderStateAsync()
+ {
+ var state = _persistentState;
+ if (state is null) return Task.CompletedTask;
+
+ foreach (var kv in _loaderStateToPersist)
+ {
+ // A null capture means the value wasn't serializable under the active resolver/options;
+ // skip it so the loader simply re-runs on the interactive pass.
+ var captured = BroutePrerenderState.Capture(kv.Value, _loaderStateJsonOptions);
+ if (captured is not null)
+ {
+ state.PersistAsJson(kv.Key, captured);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ // Attempts to restore a loader result persisted during prerender. Returns true (with the restored value,
+ // which may legitimately be null) when the loader should be skipped for this navigation.
+ [UnconditionalSuppressMessage("Trimming", "IL2026",
+ Justification = "Only reached when the consumer opts into Options.PersistLoaderState; see PersistLoaderStateAsync.")]
+ [UnconditionalSuppressMessage("AOT", "IL3050",
+ Justification = "See above; PersistLoaderState is opt-in and documents its AOT limitations.")]
+ private bool TryRestoreLoaderState(BrouterLocation to, int chainIndex, out object? value)
+ {
+ value = null;
+ var state = _persistentState;
+ if (state is null) return false;
+
+ var key = BroutePrerenderState.MakeKey(to.Path, to.Query, chainIndex);
+ if (state.TryTakeFromJson(key, out var persisted) is false) return false;
+
+ return BroutePrerenderState.TryRestore(persisted, out value, _loaderStateJsonOptions);
+ }
+
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Yield once so ComponentBase performs the initial synchronous render of our
- // ChildContent. That first render is what causes the declared children to
+ // ChildContent. That first render is what causes the declared children to
// register themselves with us (each one calls RegisterRoute from its own OnInitialized).
// Until they've registered there is nothing to match against, which is why the initial
// match cannot run any earlier than this.
@@ -138,36 +741,86 @@ protected override async Task OnInitializedAsync()
await Task.Yield();
// Initial render: the From is Empty (we just mounted), the To is the URL we're at now.
- await ProcessNavigationAsync(BrouterLocation.Empty, CurrentLocation);
+ // decisionAlreadyMade is false - the LocationChanging handler is not registered yet (and does
+ // not fire for the initial load anyway), so the full pipeline runs the guards here. The first
+ // mount is reported as a Push (a fresh navigation), never a Pop.
+ await ProcessNavigationAsync(BrouterLocation.Empty, CurrentLocation, decisionAlreadyMade: false, BrouterNavigationType.Push);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
- if (firstRender is false) return;
+ if (firstRender)
+ {
+ // Enabling navigation interception genuinely requires an interactive runtime, so it stays
+ // in OnAfterRenderAsync, which only runs once interactivity is established. Under prerender
+ // this method doesn't run at all - that's fine: the initial match already happened in
+ // OnInitializedAsync, and interception is enabled here once the component goes interactive.
+ //
+ // Enabling navigation interception is best-effort: on a disconnected circuit or an interop
+ // failure it can throw, but the navigation pipeline itself (and any subsequent reconnects /
+ // interactivity handoff) does not depend on it succeeding right now. Mirror the defensive
+ // style used in BrouterLink and BrouterService.BackAsync so a transient failure here can't
+ // kill navigation. Once the circuit/runtime is fully ready, Blazor will retry interception
+ // attachment naturally on the next user click via NavigationManager fallback paths.
+ try
+ {
+ await _navInterception.EnableNavigationInterceptionAsync();
+ }
+ catch (JSDisconnectedException) { /* circuit disconnected before/during interop */ }
+ catch (JSException) { /* JS interop failure; non-fatal */ }
+ catch (InvalidOperationException) { /* interop unavailable during prerender */ }
+ catch (TaskCanceledException) { /* component disposed mid-call */ }
+
+ // Arm the always-on external-navigation confirmation once interactive (the JS side is
+ // idempotent; runtime toggling goes through IBrouter.SetConfirmExternalNavigationAsync).
+ if (Options.ConfirmExternalNavigation)
+ {
+ await _brouterService.SetConfirmExternalNavigationAsync(true);
+ }
- // Enabling navigation interception genuinely requires an interactive runtime, so it stays
- // in OnAfterRenderAsync, which only runs once interactivity is established. Under prerender
- // this method doesn't run at all - that's fine: the initial match already happened in
- // OnInitializedAsync, and interception is enabled here once the component goes interactive.
- //
- // Enabling navigation interception is best-effort: on a disconnected circuit or an interop
- // failure it can throw, but the navigation pipeline itself (and any subsequent reconnects /
- // interactivity handoff) does not depend on it succeeding right now. Mirror the defensive
- // style used in BrouterLink and BrouterService.BackAsync so a transient failure here can't
- // kill navigation. Once the circuit/runtime is fully ready, Blazor will retry interception
- // attachment naturally on the next user click via NavigationManager fallback paths.
- try
+ // Register the preventive navigation handler now that the runtime is interactive.
+ // RegisterLocationChangingHandler (NET 7+) runs BEFORE the URL commits to history, so a
+ // guard / OnNavigating hook that cancels or redirects prevents the navigation outright
+ // (LocationChangingContext.PreventNavigation) instead of reactively "undoing" a URL change
+ // that already happened. This is what makes guards preventive rather than reactive: no
+ // address-bar flicker, no corrupted history on a cancelled Back, and real "unsaved changes"
+ // prompts become possible. LocationChanged is kept only for the commit phase (loaders +
+ // render). During static prerender this method never runs, so the handler simply isn't
+ // registered there - which is correct, since there is no interactive navigation to guard.
+ _locationChangingRegistration ??= _navManager.RegisterLocationChangingHandler(OnLocationChanging);
+ }
+
+ // Apply any post-navigation DOM effects (fragment/top scroll, focus) staged by the last
+ // committed navigation. Running here - after the render batch has been applied to the DOM -
+ // is what lets fragment (#section) and focus selectors resolve against the newly rendered
+ // route instead of the previous page. Exchange to null so each staged navigation's effects
+ // run exactly once; a navigation with nothing pending is a no-op. During static prerender
+ // this method never runs, so effects are correctly skipped server-side (no DOM/JS there).
+ var pending = Interlocked.Exchange(ref _pendingEffectsLocation, null);
+ if (pending is not null)
+ {
+ await _brouterService.ApplyNavigationEffectsAsync(pending);
+ }
+
+ // Complete the navigation's View Transition after the effects above, so the incoming
+ // snapshot the browser animates to already reflects the final scroll/focus state.
+ if (_pendingViewTransitionCompletion)
{
- await _navInterception.EnableNavigationInterceptionAsync();
+ _pendingViewTransitionCompletion = false;
+ await _brouterService.CompleteViewTransitionAsync();
}
- catch (JSDisconnectedException) { /* circuit disconnected before/during interop */ }
- catch (JSException) { /* JS interop failure; non-fatal */ }
- catch (InvalidOperationException) { /* interop unavailable during prerender */ }
- catch (TaskCanceledException) { /* component disposed mid-call */ }
}
+ [UnconditionalSuppressMessage("Trimming", "IL2110",
+ Justification = "The Broute.Component backing field requires DynamicallyAccessedMembers.All; the value " +
+ "assigned here is BrouteScanner.DiscoveredRoute.ComponentType, whose property carries the " +
+ "same annotation, so the requirement is satisfied.")]
+ [UnconditionalSuppressMessage("Trimming", "IL2111",
+ Justification = "Broute.Component's setter has a DynamicallyAccessedMembers.All parameter and is invoked " +
+ "via Blazor's reflection-based component parameter binding. DiscoveredRoute.ComponentType " +
+ "carries the matching annotation, so the members are preserved.")]
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
// Sequence numbers are per RenderFragment scope: each lambda passed to
@@ -184,12 +837,74 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddAttribute(3, "ChildContent", (RenderFragment)(b =>
{
b.AddContent(0, ChildContent);
+
+ // Emit a synthetic for each attribute-discovered route. They register themselves
+ // exactly like hand-declared children (via Broute.OnInitialized) and so participate in the
+ // same specificity-based matching, guards, loaders and rendering. Wrapped in a region so
+ // their sequence numbers live in an isolated space, and keyed by the (template, type) pair
+ // so Blazor keeps instances stable if the discovered set is reordered or grows at runtime.
+ //
+ // Known trade-off (scalability): unlike the built-in Router - which builds a plain RouteTable
+ // and instantiates only the matched component - every route here (hand-declared and discovered
+ // alike) exists as a permanently-mounted Broute in the render tree, each carrying a
+ // BrouterRouteRenderer, its cached template/parameter dictionaries and cascading-value
+ // subscriptions. An unmatched Broute renders nothing (BuildRenderTree short-circuits on the
+ // Matched flag), so the steady-state cost is memory/instances rather than render work, and the
+ // first-segment index (GetRouteIndex) keeps per-navigation match cost off the full O(routes)
+ // scan - but it does NOT reduce this instantiation cost. Fine for typical apps; for very large
+ // route sets (hundreds of pages) benchmark against the built-in Router and consider splitting
+ // routes across lazily-loaded assemblies. See the README "Performance & scalability" section.
+ if (_discoveredRoutes.Count > 0)
+ {
+ b.OpenRegion(1);
+ // Fixed cascading marker (never changes, so no subscriptions) flagging every Broute in
+ // this region as attribute-discovered. RegisterRoute's ambiguity check uses it to let a
+ // hand-declared route deliberately shadow a discovered one (see Broute.IsDiscovered).
+ b.OpenComponent>(0);
+ b.AddAttribute(1, "Name", "IsDiscoveredRoute");
+ b.AddAttribute(2, "Value", true);
+ b.AddAttribute(3, "IsFixed", true);
+ b.AddAttribute(4, "ChildContent", (RenderFragment)(b1 =>
+ {
+ var seq = 0;
+ foreach (var discovered in _discoveredRoutes)
+ {
+ b1.OpenComponent(seq++);
+ b1.SetKey(discovered);
+ b1.AddAttribute(seq++, nameof(Broute.Path), discovered.Template);
+ b1.AddAttribute(seq++, nameof(Broute.Component), discovered.ComponentType);
+ b1.AddAttribute(seq++, nameof(Broute.BindComponentParametersByName), true);
+ b1.CloseComponent();
+ }
+ }));
+ b.CloseComponent();
+ b.CloseRegion();
+ }
+
// Render the inline fallback when no route matched and either NotFound is unset, or
// NotFound resolves to the current URL (no redirect happened, so we'd otherwise show nothing).
if (_noRouteMatched && NotFoundContent is not null &&
(string.IsNullOrEmpty(NotFound) || IsSamePath(CurrentLocation.Path, NotFound)))
{
- b.AddContent(1, NotFoundContent(CurrentLocation));
+ b.AddContent(2, NotFoundContent(CurrentLocation));
+ }
+
+ // Pending-navigation UI. Shown while the current navigation awaits its loaders (see the
+ // pending-UI handling in ProcessNavigationAsync). The declared/discovered children
+ // above stay in the tree so they remain registered, but each renders nothing while unmatched
+ // (Matched is reset to false for the duration of the load), so this fragment is what the user
+ // sees until the matched route is revealed - matching the built-in Router.Navigating.
+ if (_navigating && Navigating is not null)
+ {
+ b.AddContent(3, Navigating);
+ }
+
+ // Router-level error boundary. Set only when a commit-phase failure found no route-level
+ // ErrorContent to bubble to (see RenderNavigationError); the matched chain is unmatched at
+ // that point, so this fragment is what the user sees in place of the routed content.
+ if (_navError is not null && ErrorContent is not null)
+ {
+ b.AddContent(4, ErrorContent(_navError));
}
}));
builder.CloseComponent();
@@ -216,7 +931,7 @@ private async void NavManagerLocationChanged(object? sender, LocationChangedEven
}
catch (Exception ex)
{
- // Defense in depth: ComputeLocation is intended to be no-throw (it normalises
+ // Defense in depth: ComputeLocation is intended to be no-throw (it normalizes
// off-base URLs to an empty-path location), but if a future change ever lets an
// exception escape, we still surface it through OnError instead of letting it
// out of the async-void event handler.
@@ -224,9 +939,28 @@ private async void NavManagerLocationChanged(object? sender, LocationChangedEven
return;
}
+ // Did the preventive "changing" phase already run OnNavigating + guards for this exact
+ // target and approve it? If so, commit without re-running those side-effecting hooks.
+ // Otherwise this is a navigation the changing handler never saw (initial load, forceLoad,
+ // or a nav that raced ahead of interception being enabled) - run the full pipeline, which
+ // still honours guards/OnNavigating, falling back to the reactive URL-restore behavior.
+ var approved = _approvedTargetUri;
+ _approvedTargetUri = null;
+ var approvedType = _approvedNavigationType;
+ _approvedNavigationType = null;
+ var decisionAlreadyMade =
+ approved is not null && string.Equals(approved, to.FullUri, StringComparison.Ordinal);
+
+ // When the changing phase already ran, re-use the type it resolved and stashed alongside the
+ // approval. Otherwise (initial load races, forceLoad, a nav that outran interception) resolve it
+ // here from our pending marker + whether the framework saw an intercepted link click.
+ var navType = decisionAlreadyMade
+ ? (approvedType ?? BrouterNavigationType.Push)
+ : ConsumeNavigationType(isInitial: false, isIntercepted: e.IsNavigationIntercepted);
+
try
{
- await InvokeAsync(() => ProcessNavigationAsync(from, to).AsTask());
+ await InvokeAsync(() => ProcessNavigationAsync(from, to, decisionAlreadyMade, navType).AsTask());
}
catch (Exception ex)
{
@@ -238,6 +972,285 @@ private async void NavManagerLocationChanged(object? sender, LocationChangedEven
}
}
+#if NET10_0_OR_GREATER
+ // True while Brouter itself is calling NavigationManager.NotFound() to propagate a router-level
+ // "no route matched" to the framework (so static SSR responds with HTTP 404 instead of a 200
+ // carrying fallback HTML). Our own OnNotFound event handler must no-op for that call: the
+ // pipeline is already rendering the fallback, and reacting again would double-handle it.
+ private bool _raisingFrameworkNotFound;
+
+ ///
+ /// Whether the component is being rendered by an interactive renderer. Unknown hosts (unit-test
+ /// renderers that don't populate RendererInfo) are treated as interactive, which keeps the
+ /// framework-404 propagation (a static-SSR-only concern) switched off there.
+ ///
+ private bool IsInteractiveRuntime
+ {
+ get
+ {
+ try { return RendererInfo.IsInteractive; }
+ catch (InvalidOperationException) { return true; }
+ }
+ }
+
+ ///
+ /// Handles NavigationManager.OnNotFound (.NET 10): application code called
+ /// NavigationManager.NotFound() to report a missing resource (e.g. a page whose entity
+ /// lookup failed). Brouter takes over rendering: it fires its own hook,
+ /// then either redirects to or renders in
+ /// place, and signals the framework via NotFoundEventArgs.Path that the rendering is
+ /// handled (mirroring the built-in Router's contract).
+ ///
+ private void NavManagerOnNotFound(object? sender, NotFoundEventArgs args)
+ {
+ if (_raisingFrameworkNotFound) return;
+
+ // Signal "handled" synchronously (the framework reads args after the event returns). Only
+ // claim it when this Brouter actually has a fallback to show; otherwise leave the args
+ // untouched so the framework's own not-found handling (status codes / re-execution) runs.
+ if (string.IsNullOrEmpty(NotFound) is false)
+ {
+ args.Path = NotFound;
+ }
+ else if (NotFoundContent is not null)
+ {
+ args.Path = CurrentLocation.Path;
+ }
+ else
+ {
+ return;
+ }
+
+ _ = HandleAppNotFoundAsync();
+ }
+
+ // Async continuation of NavManagerOnNotFound, dispatched onto the renderer like the
+ // LocationChanged handler. Exceptions surface through OnError, never out of the event.
+ private async Task HandleAppNotFoundAsync()
+ {
+ var location = CurrentLocation;
+ try
+ {
+ await InvokeAsync(async () =>
+ {
+ // Unmatch the currently rendered chain so the fallback replaces the page content -
+ // the URL deliberately stays put (the resource at this URL is what's missing).
+ foreach (var r in GetRoutesSnapshot()) r.Matched = false;
+ _noRouteMatched = true;
+
+ if (OnNotFound is not null) await OnNotFound(location);
+
+ if (string.IsNullOrEmpty(NotFound) is false && IsSamePath(location.Path, NotFound) is false)
+ {
+ NavigateInternal(NotFound);
+ return;
+ }
+
+ StateHasChanged();
+ });
+ }
+ catch (Exception ex)
+ {
+ await SafeInvokeOnError(location, location, ex);
+ }
+ }
+#endif
+
+ ///
+ /// Preventive navigation decision. Registered via NavigationManager.RegisterLocationChangingHandler
+ /// so it runs BEFORE the URL commits to history. Runs the OnNavigating hooks and route guards for the
+ /// pending target and, if any of them cancels or redirects, calls
+ /// so the navigation never happens - instead of
+ /// letting the URL change and reactively undoing it. When the decision approves, the navigation is
+ /// allowed to commit and the subsequent LocationChanged event runs the commit phase (loaders + render).
+ ///
+ ///
+ /// Only the decision (OnNavigating + guards + redirect/cancel + RedirectTo + NotFound-redirect) lives
+ /// here. Loaders and rendering deliberately stay in the commit phase: they produce and show the new
+ /// view, which is meaningful only once navigation is committed. This mirrors the issue's guidance to
+ /// "keep LocationChanged only for the commit phase".
+ ///
+ private async ValueTask OnLocationChanging(LocationChangingContext context)
+ {
+ // Clear any prior approval up front: only an outcome that actually approves THIS navigation
+ // below may set it. This guarantees a decision that ends up cancelled, redirected, superseded
+ // or errored never leaves a stale approval that a later commit could misread as "guards ran".
+ _approvedTargetUri = null;
+ _approvedNavigationType = null;
+
+ // Resolve the navigation type now (before hooks/guards run) so they can read it off the context.
+ // Consuming the pending marker here is what keeps it from leaking into a later navigation.
+ var navType = ConsumeNavigationType(isInitial: false, isIntercepted: context.IsNavigationIntercepted);
+
+ BrouterLocation from = CurrentLocation;
+ BrouterLocation to;
+ try
+ {
+ // The URL has NOT committed yet, so resolve the pending target rather than
+ // NavigationManager.Uri (which still holds the current location). The pending entry's
+ // history state likewise comes from the changing context, not the NavigationManager.
+ to = ComputeLocation(_navManager.ToAbsoluteUri(context.TargetLocation).ToString(), context.HistoryEntryState);
+ }
+ catch (Exception ex) when (ex is ArgumentException or UriFormatException or InvalidOperationException)
+ {
+ // Malformed / off-base target. Let the navigation commit and be handled by the commit
+ // phase (which routes it through NotFound / OnError). Do not block on a parse failure.
+ return;
+ }
+
+ // Supersession here rides on the framework: context.CancellationToken is cancelled when a
+ // newer navigation starts, so guards/hooks that await observe it and bail. We deliberately
+ // do NOT touch _navCts / _navVersion in this phase - that machinery belongs to the commit
+ // phase, and mixing the two would leak or double-cancel token sources.
+ var token = context.CancellationToken;
+ var ctx = new BrouterNavigationContext(from, to, token) { NavigationType = navType };
+ var service = _brouterService;
+
+ try
+ {
+ // Lazy route loading first: the target may live in an assembly that isn't loaded yet,
+ // and everything below (leave-guard staying-set, matching, guards) needs the final
+ // route set to reason correctly.
+ await RunOnNavigateHookAsync(ctx);
+ if (token.IsCancellationRequested) return;
+ if (ApplyPreventiveDecision(context, ctx)) return;
+
+ // Leave guards run first (Vue's beforeRouteLeave / Angular's CanDeactivate ordering):
+ // the routes being deactivated get the first chance to veto, before global hooks and
+ // any enter guards on the target.
+ var leaveOk = await InvokeLeaveGuardsAsync(to, ctx);
+ if (token.IsCancellationRequested) return;
+ if (ApplyPreventiveDecision(context, ctx)) return;
+ if (leaveOk is false) return; // superseded inside the leave chain
+
+ await service.InvokeOnNavigating(ctx);
+ if (token.IsCancellationRequested) return;
+ if (ApplyPreventiveDecision(context, ctx)) return;
+
+ var winnerMatch = SelectWinner(to);
+
+ if (winnerMatch is null)
+ {
+ // No route matched. Fire OnNotFound, then either redirect to the NotFound target
+ // (preventively, so the unmatched URL never appears in the address bar) or allow
+ // the commit phase to render NotFoundContent in place.
+ if (OnNotFound is not null) await OnNotFound(to);
+ if (token.IsCancellationRequested) return;
+
+ if (string.IsNullOrEmpty(NotFound) is false && IsSamePath(to.Path, NotFound) is false)
+ {
+ context.PreventNavigation();
+ ResolveNavigationOutcome(to.FullUri, BrouterNavigationOutcome.NotFound());
+ NavigateInternal(NotFound);
+ return;
+ }
+
+ _approvedTargetUri = to.FullUri;
+ _approvedNavigationType = navType;
+ return;
+ }
+
+ var winner = winnerMatch.Value.Route;
+ ctx.Route = winner;
+ ctx.Parameters = new BrouterRouteParameters(winnerMatch.Value.Parameters);
+
+ var guardsOk = await winner.InvokeGuardsAsync(ctx);
+ if (token.IsCancellationRequested) return;
+ if (ApplyPreventiveDecision(context, ctx)) return;
+ if (guardsOk is false) return; // superseded (token cancelled inside the guard chain)
+
+ if (winner.RedirectTo is not null)
+ {
+ context.PreventNavigation();
+ ResolveNavigationOutcome(to.FullUri, BrouterNavigationOutcome.Redirected(winner.RedirectTo));
+ NavigateInternal(winner.RedirectTo);
+ return;
+ }
+
+ // Approved: let the URL commit. The LocationChanged commit phase re-selects this same
+ // winner (matching is pure) and runs its loaders + render, skipping the hooks above.
+ _approvedTargetUri = to.FullUri;
+ _approvedNavigationType = navType;
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ // The navigation was superseded while a guard/hook was awaiting. The framework has
+ // already cancelled it, so there is nothing to prevent and no error to report.
+ }
+ catch (Exception ex)
+ {
+ // A guard / OnNavigating hook threw. Fail closed: block the navigation rather than
+ // committing into a state whose authorization never completed, and surface the error.
+ context.PreventNavigation();
+ ResolveNavigationOutcome(to.FullUri, BrouterNavigationOutcome.Failed(ex));
+ await SafeInvokeOnError(from, to, ex);
+ }
+ }
+
+ ///
+ /// Translates a cancel/redirect request captured on (by an OnNavigating
+ /// hook or a guard) into a preventive outcome on . Returns true when the
+ /// navigation has been handled (prevented, and redirected if applicable) and the caller should stop.
+ ///
+ private bool ApplyPreventiveDecision(LocationChangingContext context, BrouterNavigationContext ctx)
+ {
+ if (ctx.RedirectUrl is not null)
+ {
+ context.PreventNavigation();
+ // Resolve the awaited outcome BEFORE triggering the redirect: the redirect's own
+ // pipeline starts (synchronously on some hosts) and would otherwise supersede the
+ // still-pending awaiter of the navigation we're concluding here.
+ ResolveNavigationOutcome(ctx.To.FullUri, BrouterNavigationOutcome.Redirected(ctx.RedirectUrl));
+ NavigateInternal(ctx.RedirectUrl);
+ return true;
+ }
+
+ if (ctx.IsCancelled)
+ {
+ context.PreventNavigation();
+ ResolveNavigationOutcome(ctx.To.FullUri, BrouterNavigationOutcome.Cancelled());
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Resolves the for a navigation and consumes the pending marker.
+ /// A pending type (stamped by /
+ /// for Brouter's own programmatic navigations) wins. Otherwise the first mount and an intercepted link
+ /// click are pushes, and a non-intercepted navigation with no pending marker is a history traversal
+ /// (Back/Forward) - reported as . Consuming the pending marker
+ /// here (exactly once, in whichever phase runs first) is what stops it leaking into a later navigation.
+ ///
+ private BrouterNavigationType ConsumeNavigationType(bool isInitial, bool isIntercepted)
+ {
+ var pending = _pendingNavigationType;
+ _pendingNavigationType = null;
+ if (pending is not null) return pending.Value;
+ if (isInitial || isIntercepted) return BrouterNavigationType.Push;
+ return BrouterNavigationType.Pop;
+ }
+
+ ///
+ /// Marks the next navigation this Brouter is about to trigger programmatically as a push or a
+ /// replace, then delegates to NavigationManager.NavigateTo. Used for all of Brouter's own
+ /// internal navigations (redirects, NotFound redirect, cancelled-navigation address-bar restore) so
+ /// the phase that observes the resulting navigation classifies it correctly instead of guessing.
+ ///
+ private void NavigateInternal(string url, bool replace = false)
+ {
+ _pendingNavigationType = replace ? BrouterNavigationType.Replace : BrouterNavigationType.Push;
+ _navManager.NavigateTo(url, replace: replace);
+ }
+
+ ///
+ /// Records the type of the next navigation the caller is about to trigger via
+ /// NavigationManager.NavigateTo. Called by before an
+ /// so the pipeline reports the correct push/replace type.
+ ///
+ internal void SetPendingNavigationType(BrouterNavigationType type) => _pendingNavigationType = type;
+
private async ValueTask SafeInvokeOnError(BrouterLocation from, BrouterLocation to, Exception ex)
{
try
@@ -249,19 +1262,30 @@ await _brouterService.InvokeOnError(
}
///
- /// Pure: builds a from the current NavigationManager.Uri.
- /// Does not mutate . Never throws: an off-base URL or other
- /// malformed input is normalised to an empty-path location so the navigation pipeline can
- /// run and surface the issue through NotFound / OnError instead of crashing the handler.
+ /// Pure: builds a from the current NavigationManager.Uri
+ /// (and the current entry's HistoryEntryState). Does not mutate
+ /// . Never throws: an off-base URL or other malformed input is
+ /// normalized to an empty-path location so the navigation pipeline can run and surface the
+ /// issue through NotFound / OnError instead of crashing the handler.
///
- private BrouterLocation ComputeLocation()
- {
- var uri = _navManager.Uri;
+ private BrouterLocation ComputeLocation() => ComputeLocation(_navManager.Uri, _navManager.HistoryEntryState);
+ ///
+ /// Pure: builds a from an arbitrary absolute URI. Used by the
+ /// LocationChanging handler, where the navigation has not committed yet so we must resolve the
+ /// pending target URL (from LocationChangingContext.TargetLocation) rather than the still
+ /// current NavigationManager.Uri. Shares all normalization with the no-arg overload so a
+ /// location computed during the "changing" phase is identical to the one recomputed after commit.
+ /// is the history-entry state travelling with the navigation
+ /// (from NavigationManager.HistoryEntryState after commit, or
+ /// LocationChangingContext.HistoryEntryState for a pending target).
+ ///
+ private BrouterLocation ComputeLocation(string uri, string? historyState = null)
+ {
// ToBaseRelativePath throws ArgumentException if the current Uri is not within
// NavigationManager.BaseUri (base href misconfigured, programmatic NavigateTo to an
// off-base absolute URL, etc.). Don't propagate: that would kill an async-void
- // handler permanently. Synthesise an empty-path location so the pipeline runs and
+ // handler permanently. Synthesize an empty-path location so the pipeline runs and
// typically routes through NotFound, which surfaces the issue cleanly.
string raw;
try
@@ -270,7 +1294,7 @@ private BrouterLocation ComputeLocation()
}
catch (ArgumentException)
{
- return new BrouterLocation(uri, "/", [], "", "");
+ return new BrouterLocation(uri, "/", [], "", "", historyState: historyState);
}
var hashIndex = raw.IndexOf('#');
@@ -314,20 +1338,20 @@ private BrouterLocation ComputeLocation()
catch (UriFormatException) { /* keep the raw, still-escaped segment */ }
}
- return new BrouterLocation(uri, path, rawSegments, query, hash, hasTrailingSlash);
+ return new BrouterLocation(uri, path, rawSegments, query, hash, hasTrailingSlash, historyState);
}
- // Cache the most recently-computed normalisation. NotFound is typically a constant per
+ // Cache the most recently-computed normalization. NotFound is typically a constant per
// Brouter instance, and BuildRenderTree calls IsSamePath on every render (NotFoundContent
// fallback check). One-slot cache is enough; on a NotFound parameter change the cached
// entry is replaced.
private string? _isSamePathCacheTarget;
- private string? _isSamePathCacheNormalised;
+ private string? _isSamePathCacheNormalized;
///
- /// Compares an already-normalised (as produced by
- /// ) against an arbitrary target URL/path. Returns true
- /// when their normalised path components are equal.
+ /// Compares an already-normalized (as produced by
+ /// ) against an arbitrary target URL/path. Returns true
+ /// when their normalized path components are equal.
///
///
/// Used by the NotFound logic to detect the "we're already at the NotFound target"
@@ -345,10 +1369,10 @@ private bool IsSamePath(string currentPath, string target)
|| string.Equals(_isSamePathCacheTarget, target, StringComparison.Ordinal))
{
// Cache hit: skip the ToAbsoluteUri / ToBaseRelativePath / split work.
- // _isSamePathCacheNormalised is null only when the previous call returned false
+ // _isSamePathCacheNormalized is null only when the previous call returned false
// for an off-base/malformed target; replicate that result.
- if (_isSamePathCacheNormalised is null) return false;
- targetPath = _isSamePathCacheNormalised;
+ if (_isSamePathCacheNormalized is null) return false;
+ targetPath = _isSamePathCacheNormalized;
}
else
{
@@ -364,7 +1388,7 @@ private bool IsSamePath(string currentPath, string target)
{
// Off-base or malformed target: not equal to anything we'd legitimately be at.
_isSamePathCacheTarget = target;
- _isSamePathCacheNormalised = null;
+ _isSamePathCacheNormalized = null;
return false;
}
@@ -380,14 +1404,23 @@ private bool IsSamePath(string currentPath, string target)
}
_isSamePathCacheTarget = target;
- _isSamePathCacheNormalised = targetPath;
+ _isSamePathCacheNormalized = targetPath;
}
var comparison = Options.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
return string.Equals(currentPath, targetPath, comparison);
}
- private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLocation to)
+ ///
+ /// The navigation commit pipeline: publishes the target location, (re)matches the route, runs
+ /// loaders and renders. When is true the preventive
+ /// phase has already run the OnNavigating hooks and guards for
+ /// this target and approved it, so those side-effecting steps (and the cancel/redirect handling)
+ /// are skipped here to avoid running them twice. When false - the initial load, a forceLoad, or a
+ /// navigation the changing handler never observed - the full pipeline runs, including guards and
+ /// the reactive URL-restore fallback in .
+ ///
+ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLocation to, bool decisionAlreadyMade, BrouterNavigationType navType)
{
// Now that we own the renderer's dispatcher (via InvokeAsync from the LocationChanged
// handler, or directly from OnAfterRenderAsync for the initial render), publish the
@@ -409,17 +1442,69 @@ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLoca
oldCts?.Cancel();
var token = newCts.Token;
- var ctx = new BrouterNavigationContext(from, to, token);
+ var ctx = new BrouterNavigationContext(from, to, token) { NavigationType = navType };
var service = _brouterService;
+ // Awaited-navigation bookkeeping: capture the awaiter this pipeline is responsible for (the
+ // one registered for exactly this target), and supersede any pending awaiter for a DIFFERENT
+ // target - a pipeline for another URL starting is precisely what "superseded" means for it.
+ var myOutcome = string.Equals(_pendingOutcomeUri, to.FullUri, StringComparison.Ordinal) ? _pendingOutcome : null;
+ if (myOutcome is null && _pendingOutcome is not null)
+ {
+ var stale = _pendingOutcome;
+ _pendingOutcome = null;
+ _pendingOutcomeUri = null;
+ stale.TrySetResult(BrouterNavigationOutcome.Superseded());
+ }
+
+ // A fresh navigation supersedes any previously rendered error boundary; if it fails too,
+ // RenderNavigationError re-establishes the right one for the new failure.
+ _navError = null;
+
+ // View-transition state for THIS pipeline: started = JS snapshotted the outgoing DOM and
+ // holds an open update promise; staged = the success path scheduled its completion for
+ // OnAfterRenderAsync. The finally block completes a started-but-unstaged transition so a
+ // failed/superseded navigation can never leave the browser holding a frozen snapshot.
+ var viewTransitionStarted = false;
+ var viewTransitionStaged = false;
+
try
{
+ // Remember where the page we're leaving was scrolled to, keyed by its URL, so a later
+ // Back/Forward to it can restore the position. Done here - before StateHasChanged renders
+ // the new route - so the JS side reads the OUTGOING page's scroll offset, not the new
+ // one's. Awaited so the read is ordered ahead of the render batch on Blazor Server too.
+ // No-op unless Options.RestoreScrollPosition is enabled and `from` is a real page.
+ await service.SaveScrollPositionAsync(from);
+
// No ConfigureAwait(false) anywhere in this pipeline: subsequent calls
// (StateHasChanged, NavigationManager.NavigateTo, route/component state mutations,
// Outlet rendering) require the Blazor renderer's synchronization context.
- await service.InvokeOnNavigating(ctx);
- if (HandleSideEffects(ctx, from)) return;
- if (token.IsCancellationRequested || version != _navVersion) return;
+ //
+ // OnNavigating (and its cancel/redirect handling) only runs when the preventive changing
+ // phase did NOT already run it. When decisionAlreadyMade is true, OnLocationChanging has
+ // run these hooks and approved the navigation, so re-running them here would double-fire
+ // side effects.
+ if (decisionAlreadyMade is false)
+ {
+ // Lazy route loading for navigations the preventive phase never saw (notably the
+ // initial load, where a deep link may target a lazily-loaded page).
+ await RunOnNavigateHookAsync(ctx);
+ if (HandleSideEffects(ctx, from)) return;
+ if (token.IsCancellationRequested || version != _navVersion) return;
+
+ // Leave guards for navigations the preventive phase never saw (initial load never has
+ // a committed chain, but a forceLoad-less nav that outran interception does). On this
+ // reactive path a cancel restores the URL via HandleSideEffects instead of preventing it.
+ var leaveOk = await InvokeLeaveGuardsAsync(to, ctx);
+ if (HandleSideEffects(ctx, from)) return;
+ if (token.IsCancellationRequested || version != _navVersion) return;
+ if (leaveOk is false) return;
+
+ await service.InvokeOnNavigating(ctx);
+ if (HandleSideEffects(ctx, from)) return;
+ if (token.IsCancellationRequested || version != _navVersion) return;
+ }
// Snapshot the route list before any awaits / chain walks below: routes can register
// or unregister during awaits (component lifecycle on the renderer dispatcher), and
@@ -427,96 +1512,118 @@ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLoca
// reused across navigations while the registration set is stable - see GetRoutesSnapshot.
var routesSnapshot = GetRoutesSnapshot();
- // Match routes. Match is pure: it returns a MatchResult and never mutates the route.
- foreach (var r in routesSnapshot) r.Matched = false;
- var candidates = new List();
+ // Reset the previous match's render flags before selecting the new winner. This lives in
+ // the commit phase (never the preventive changing phase): blanking Matched before the URL
+ // commits could unrender the current route while a guard is still deciding. No render can
+ // interleave between here and the SetMatched below (no StateHasChanged until the end), so
+ // the reset is invisible to the user. CurrentError travels with Matched: a route that was
+ // an error boundary for the previous navigation must not re-render stale error UI when a
+ // later navigation matches it again.
foreach (var r in routesSnapshot)
{
- if (TryMatch(r, to.SegmentsArray, to.HasTrailingSlash, out var result))
- {
- candidates.Add(result);
- }
+ r.Matched = false;
+ r.CurrentError = null;
}
- if (candidates.Count == 0)
+ // Match is pure (SelectWinner never mutates a route), so the same selection runs in both
+ // the changing and commit phases and yields the same winner for a stable route set.
+ var winnerMatch = SelectWinner(to);
+
+ if (winnerMatch is null)
{
_noRouteMatched = true;
- if (OnNotFound is not null) await OnNotFound(to);
+ // Nothing routed is on screen once the fallback renders below; leave guards of the
+ // previous chain already ran for this navigation. Either way this navigation's
+ // awaited outcome is NotFound (resolved before any NotFound redirect fires).
+ _committedChain = [];
+ ResolveNavigationOutcome(to.FullUri, BrouterNavigationOutcome.NotFound());
+
+ // OnNotFound + the preventive NotFound redirect already ran in the changing phase
+ // when decisionAlreadyMade is true; only run them here for the full-pipeline path.
+ if (decisionAlreadyMade is false)
+ {
+ if (OnNotFound is not null) await OnNotFound(to);
- // The OnNotFound handler may have awaited; if a newer navigation has started or
- // this one was cancelled in the meantime, abandon the fallback path so we don't
- // redirect/render on behalf of a superseded navigation.
- if (token.IsCancellationRequested || version != _navVersion) return;
+ // The OnNotFound handler may have awaited; if a newer navigation has started or
+ // this one was cancelled in the meantime, abandon the fallback path so we don't
+ // redirect/render on behalf of a superseded navigation.
+ if (token.IsCancellationRequested || version != _navVersion) return;
- if (string.IsNullOrEmpty(NotFound) is false)
+ if (string.IsNullOrEmpty(NotFound) is false)
+ {
+ // Avoid a self-redirect loop when the current URL is already the NotFound target
+ // (and still doesn't match any route). Render the fallback UI instead.
+ // Compare normalized base-relative paths rather than raw absolute URIs:
+ // "http://host/x" vs "http://host/x/" or vs "http://host/x?foo=1" would
+ // otherwise miss the equality check and trigger an infinite redirect loop
+ // (the NotFound URL keeps not matching, we keep navigating to it).
+ if (IsSamePath(to.Path, NotFound) is false)
+ {
+ NavigateInternal(NotFound);
+ return;
+ }
+ }
+ }
+#if NET10_0_OR_GREATER
+ // During static SSR/prerender an unmatched URL must produce an HTTP 404 response, not
+ // a 200 whose body happens to contain fallback HTML (an SEO/correctness bug).
+ // NavigationManager.NotFound() is the framework's channel for that status code (and it
+ // drives UseStatusCodePagesWithReExecute re-execution when configured). Interactive
+ // renderers show the fallback UI below instead - there is no response status to set.
+ if (decisionAlreadyMade is false && IsInteractiveRuntime is false)
{
- // Avoid a self-redirect loop when the current URL is already the NotFound target
- // (and still doesn't match any route). Render the fallback UI instead.
- // Compare normalised base-relative paths rather than raw absolute URIs:
- // "http://host/x" vs "http://host/x/" or vs "http://host/x?foo=1" would
- // otherwise miss the equality check and trigger an infinite redirect loop
- // (the NotFound URL keeps not matching, we keep navigating to it).
- if (IsSamePath(to.Path, NotFound) is false)
+ try
{
- _navManager.NavigateTo(NotFound);
- return;
+ _raisingFrameworkNotFound = true;
+ _navManager.NotFound();
+ }
+ finally
+ {
+ _raisingFrameworkNotFound = false;
}
}
+#endif
StateHasChanged();
return;
}
_noRouteMatched = false;
- // Pick the most specific match. Ties broken by deeper nesting (so an index child
- // wins over its parent when their full templates are identical), then by index-route
- // preference, then by declaration order.
- MatchResult winnerMatch = candidates[0];
- int winnerIndex = 0;
- for (int i = 1; i < candidates.Count; i++)
- {
- var c = candidates[i];
- var w = winnerMatch;
- int cmp = c.Route.Specificity - w.Route.Specificity;
- if (cmp == 0) cmp = c.Route.Depth - w.Route.Depth;
- if (cmp == 0) cmp = (c.Route.IsIndex ? 1 : 0) - (w.Route.IsIndex ? 1 : 0);
- if (cmp > 0)
- {
- winnerMatch = c;
- winnerIndex = i;
- }
- }
- // Suppress unused-variable warning while documenting that declaration order is the
- // final tiebreaker (lower index wins, which is what the loop above naturally yields).
- _ = winnerIndex;
-
- var winner = winnerMatch.Route;
+ var winner = winnerMatch.Value.Route;
// Commit the winner's matched parameters / constraints. Until this point Match was
// pure, so candidates that lost have not had their Parameters/Constraints touched
// (avoiding a race where a still-rendering, previously-matched route gets blanked).
- winner.Parameters = winnerMatch.Parameters;
- winner.ConstraintsByParameter = winnerMatch.ConstraintsByParameter;
+ winner.Parameters = winnerMatch.Value.Parameters;
+ winner.ConstraintsByParameter = winnerMatch.Value.ConstraintsByParameter;
ctx.Route = winner;
ctx.Parameters = new BrouterRouteParameters(winner.Parameters);
- // Guards run before RedirectTo so a guard can still authorize/cancel/redirect-elsewhere
- // (e.g. an auth guard on a redirect route, or a parent guard inherited via the chain).
- // For routes without any guards in the chain, InvokeGuardsAsync is effectively a no-op,
- // so pure redirect routes still redirect immediately below.
- var guardsOk = await winner.InvokeGuardsAsync(ctx);
- if (HandleSideEffects(ctx, from)) return;
- if (token.IsCancellationRequested || version != _navVersion) return;
- if (guardsOk is false) return;
-
- // RedirectTo: once guards pass, redirect instead of running loaders/rendering. This honors
- // the documented "redirects to the given URL instead of rendering anything" contract even
- // when Guard is also set.
- if (winner.RedirectTo is not null)
+ // Guards + RedirectTo run only on the full-pipeline path. When decisionAlreadyMade is
+ // true, the changing phase already ran the guard chain and honoured RedirectTo (a
+ // RedirectTo route would have redirected there, so this commit is only reached for
+ // routes that render).
+ if (decisionAlreadyMade is false)
{
- _navManager.NavigateTo(winner.RedirectTo);
- return;
+ // Guards run before RedirectTo so a guard can still authorize/cancel/redirect-elsewhere
+ // (e.g. an auth guard on a redirect route, or a parent guard inherited via the chain).
+ // For routes without any guards in the chain, InvokeGuardsAsync is effectively a no-op,
+ // so pure redirect routes still redirect immediately below.
+ var guardsOk = await winner.InvokeGuardsAsync(ctx);
+ if (HandleSideEffects(ctx, from)) return;
+ if (token.IsCancellationRequested || version != _navVersion) return;
+ if (guardsOk is false) return;
+
+ // RedirectTo: once guards pass, redirect instead of running loaders/rendering. This honors
+ // the documented "redirects to the given URL instead of rendering anything" contract even
+ // when Guard is also set.
+ if (winner.RedirectTo is not null)
+ {
+ ResolveNavigationOutcome(to.FullUri, BrouterNavigationOutcome.Redirected(winner.RedirectTo));
+ NavigateInternal(winner.RedirectTo);
+ return;
+ }
}
// Loaders. Walk root -> leaf so parent layouts get their data populated before
@@ -529,7 +1636,7 @@ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLoca
// Snapshot the chain BEFORE any await: a parent route can be disposed while
// an await is in-flight (conditional rendering, route tree mutation), and we
// must not walk a torn `Parent` chain afterwards.
- var matchedChain = new List();
+ var matchedChain = new List();
for (var node = winner; node is not null; node = node.Parent) matchedChain.Add(node);
matchedChain.Reverse();
@@ -562,43 +1669,211 @@ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLoca
foreach (var node in matchedChain) node.LoadedData = null;
- foreach (var node in matchedChain)
+ // Discard any loader results staged by a previous navigation: only the latest committed
+ // navigation's data should be persisted at the end of prerender.
+ if (_persistentState is not null) _loaderStateToPersist.Clear();
+
+ // First pass: restore prerendered results, consult the stale-while-revalidate cache, and
+ // collect the loaders that still need to run. The chain index is carried alongside each
+ // node because it is part of the persistence key.
+ List<(Broute Node, int ChainIndex)> pendingLoaders = [];
+ var staleServed = false;
+ for (int chainIndex = 0; chainIndex < matchedChain.Count; chainIndex++)
{
+ var node = matchedChain[chainIndex];
if (node.Loader is null) continue;
- object? loaded;
- try
+ // Prerender bridge: if this loader already ran server-side and its result was persisted,
+ // restore it and skip the fetch. The key is derived from the URL + chain position, which
+ // are identical across the prerender and interactive passes, so restoration lines up.
+ // Restored values also seed the cache so a later in-SPA return can reuse them.
+ if (TryRestoreLoaderState(to, chainIndex, out var restored))
{
- loaded = await node.Loader(ctx);
+ node.LoadedData = restored;
+ CacheLoaderResult(node, to, restored);
+ continue;
}
- catch (OperationCanceledException) when (token.IsCancellationRequested)
+
+ // SWR cache: a fresh entry skips the loader; a stale one is served immediately with a
+ // background refresh (Options.StaleReloadMode.Background) or treated as a miss
+ // (Blocking). Preload-produced entries are readable even without a configured
+ // StaleTime (see BrouterLoaderCache.TryGet).
+ if (service.LoaderCache.TryGet(
+ BrouterLoaderCache.MakeKey(node.FullTemplate, to),
+ EffectiveStaleTime(node), Options.PreloadStaleTime, Options.LoaderCacheGcTime,
+ out var cached, out var isStale))
{
- return;
+ if (isStale is false)
+ {
+ node.LoadedData = cached;
+ continue;
+ }
+ if (Options.StaleReloadMode == BrouterStaleReloadMode.Background)
+ {
+ node.LoadedData = cached;
+ staleServed = true;
+ continue;
+ }
+ // Blocking mode: stale counts as a miss - run the loader below.
}
- catch (NavigationException)
+
+ pendingLoaders.Add((node, chainIndex));
+ }
+
+ if (pendingLoaders.Count > 0)
+ {
+ // Reveal the pending-navigation UI lazily - only now that a loader is actually about to
+ // run (restored/loaderless navigations never reach here, so they never flash it). The
+ // matched chain's Matched flags were reset to false above and no render has happened yet,
+ // so this StateHasChanged replaces the routed content with the Navigating fragment for the
+ // duration of the await(s), then it's cleared before SetMatched reveals the new route.
+ if (Navigating is not null && _navigating is false)
{
- // During static server rendering / prerender, NavigationManager.NavigateTo
- // throws NavigationException as the framework's redirect signal (a loader may
- // redirect, e.g. an auth gate). It must unwind out of OnInitializedAsync so the
- // endpoint can issue the HTTP redirect; swallowing it into OnError would drop
- // the redirect entirely. Interactive NavigateTo never throws, so this is inert
- // outside SSR.
- throw;
+ _navigating = true;
+ StateHasChanged();
}
- catch (Exception ex)
+
+ if (ParallelLoaders && pendingLoaders.Count > 1)
{
- await service.InvokeOnError(ctx, ex);
- return;
+ // Opt-in parallel mode: start every pending loader at once and await them all,
+ // then process results in the same root -> leaf order sequential mode uses, so
+ // commit and failure semantics are identical - only the awaiting overlaps.
+ // Each loader's synchronous prefix still executes root -> leaf here (concurrency
+ // begins at the first await inside a loader).
+ var loaderTasks = new Task<(object? Result, Exception? Error)>[pendingLoaders.Count];
+ for (var i = 0; i < pendingLoaders.Count; i++)
+ {
+ loaderTasks[i] = RunLoaderAsync(pendingLoaders[i].Node);
+ }
+
+ var results = await Task.WhenAll(loaderTasks);
+
+ async Task<(object? Result, Exception? Error)> RunLoaderAsync(Broute node)
+ {
+ try
+ {
+ return (await node.Loader!(ctx), null);
+ }
+ catch (Exception ex)
+ {
+ return (null, ex);
+ }
+ }
+
+ // During static server rendering / prerender, NavigationManager.NavigateTo throws
+ // NavigationException as the framework's redirect signal (a loader may redirect,
+ // e.g. an auth gate). It must unwind out of OnInitializedAsync so the endpoint can
+ // issue the HTTP redirect; swallowing it into OnError would drop the redirect
+ // entirely. Scan for it before any other error handling so an SSR redirect wins
+ // over a sibling loader's failure (root-most redirect wins if several threw).
+ // Interactive NavigateTo never throws, so this is inert outside SSR.
+ foreach (var (_, error) in results)
+ {
+ if (error is NavigationException)
+ {
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(error).Throw();
+ }
+ }
+
+ for (var i = 0; i < results.Length; i++)
+ {
+ var (node, chainIndex) = pendingLoaders[i];
+ var (loaded, error) = results[i];
+
+ if (error is OperationCanceledException && token.IsCancellationRequested) return;
+ if (error is not null)
+ {
+ await service.InvokeOnError(ctx, error);
+ if (token.IsCancellationRequested || version != _navVersion) return;
+ RenderNavigationError(node, ctx, error);
+ return;
+ }
+
+ if (HandleSideEffects(ctx, from)) return;
+ if (token.IsCancellationRequested || version != _navVersion) return;
+
+ node.LoadedData = loaded;
+ CacheLoaderResult(node, to, loaded);
+
+ // Stage the result for persistence. It is written to PersistentComponentState
+ // only if the RegisterOnPersisting callback fires (i.e. during prerender);
+ // interactive passes stage it too but simply never get asked to persist.
+ if (_persistentState is not null)
+ {
+ _loaderStateToPersist[BroutePrerenderState.MakeKey(to.Path, to.Query, chainIndex)] = loaded;
+ }
+ }
+ }
+ else
+ {
+ // Default sequential mode: each loader completes before the next starts, so a
+ // child's loader can rely on work its parent's loader has already done. The cost
+ // is that the total wait is the sum of the chain's loader times - opt into
+ // ParallelLoaders when the loaders are independent.
+ foreach (var (node, chainIndex) in pendingLoaders)
+ {
+ object? loaded;
+ try
+ {
+ loaded = await node.Loader!(ctx);
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (NavigationException)
+ {
+ // SSR redirect signal - must unwind so the endpoint can issue the HTTP
+ // redirect (see the parallel branch's scan above for the full story).
+ throw;
+ }
+ catch (Exception ex)
+ {
+ await service.InvokeOnError(ctx, ex);
+ if (token.IsCancellationRequested is false && version == _navVersion)
+ {
+ RenderNavigationError(node, ctx, ex);
+ }
+ return;
+ }
+
+ if (HandleSideEffects(ctx, from)) return;
+ if (token.IsCancellationRequested || version != _navVersion) return;
+
+ node.LoadedData = loaded;
+ CacheLoaderResult(node, to, loaded);
+
+ // Stage the result for persistence. It is written to PersistentComponentState
+ // only if the RegisterOnPersisting callback fires (i.e. during prerender);
+ // interactive passes stage it too but simply never get asked to persist.
+ if (_persistentState is not null)
+ {
+ _loaderStateToPersist[BroutePrerenderState.MakeKey(to.Path, to.Query, chainIndex)] = loaded;
+ }
+ }
}
+ }
- if (HandleSideEffects(ctx, from)) return;
+ // View transition: snapshot the outgoing DOM now - after loaders (so the snapshot isn't
+ // held across arbitrary awaits) and immediately before the renders below mutate the page.
+ // The completion (which lets the browser animate to the new state) runs in
+ // OnAfterRenderAsync once the new DOM is committed.
+ if (Options.ViewTransitions)
+ {
+ viewTransitionStarted = await service.BeginViewTransitionAsync(navType);
if (token.IsCancellationRequested || version != _navVersion) return;
-
- node.LoadedData = loaded;
}
+ // Loaders are done: hide the pending-navigation UI. SetMatched marks the whole chain and
+ // issues one render request at its topmost route, which renders the now-matched route in
+ // the pending UI's place, so clearing the flag first avoids showing both at once.
+ _navigating = false;
winner.SetMatched();
+ // Record what is now on screen so the next navigation's leave guards know which routes
+ // they would deactivate.
+ _committedChain = matchedChain.ToArray();
+
if (OnMatch is not null) await OnMatch(winner);
// Each await below can yield long enough for a newer navigation to start. If that
// happens, bail out so we don't fire OnNavigated, scroll, or re-render on behalf
@@ -608,10 +1883,32 @@ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLoca
await service.InvokeOnNavigated(ctx);
if (token.IsCancellationRequested || version != _navVersion) return;
- await service.ApplyScrollAsync();
- if (token.IsCancellationRequested || version != _navVersion) return;
+ // Stage the post-navigation DOM effects (fragment/top scroll, focus). They can't run
+ // here: fragment and focus selectors must resolve against the NEW route's DOM, which
+ // isn't committed until the render triggered below flushes. OnAfterRenderAsync applies
+ // them once that render lands. Only the latest staged location is ever applied, so a
+ // superseded navigation can't scroll/focus on behalf of the page the user left.
+ _pendingEffectsLocation = to;
StateHasChanged();
+
+ // Hand the open transition to OnAfterRenderAsync: it completes once the render above has
+ // been applied to the DOM, which is exactly when the browser should snapshot the new state.
+ if (viewTransitionStarted)
+ {
+ viewTransitionStaged = true;
+ _pendingViewTransitionCompletion = true;
+ }
+
+ ResolveNavigationOutcome(to.FullUri, BrouterNavigationOutcome.Success());
+
+ // Stale-while-revalidate: some chain node rendered a stale cached result above - kick
+ // off the background refresh now that the (stale) content is committed. Revalidation
+ // re-runs the chain's loaders, updates the cache and re-renders with fresh data.
+ if (staleServed)
+ {
+ _ = RevalidateAsync();
+ }
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
@@ -627,9 +1924,48 @@ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLoca
catch (Exception ex)
{
await service.InvokeOnError(ctx, ex);
+ // Route the failure to an error boundary too (guard/OnMatch/hook errors land here; ctx.Route
+ // carries the winner when matching got that far, so bubbling starts at the right depth).
+ // Guarded by version so a superseded pipeline can't paint an error over the newer navigation.
+ if (token.IsCancellationRequested is false && version == _navVersion)
+ {
+ RenderNavigationError(ctx.Route, ctx, ex);
+ }
}
finally
{
+ // Safety net for the pending-navigation UI: if this navigation revealed it but bailed before
+ // clearing it above (e.g. a loader threw and we routed to OnError), and we're still the current
+ // navigation, hide it now. Guarded by version so a superseded pipeline can't clear the flag out
+ // from under the newer navigation that may have just turned it on.
+ if (_navigating && version == _navVersion)
+ {
+ _navigating = false;
+ StateHasChanged();
+ }
+
+ // A started transition that never made it to the success staging (loader error, guard
+ // side-effects, supersession) is released immediately: the browser must not sit on the
+ // old-page snapshot waiting for a completion that will never come.
+ if (viewTransitionStarted && viewTransitionStaged is false)
+ {
+ await service.CompleteViewTransitionAsync();
+ }
+
+ // A superseded pipeline resolves its own awaiter (a newer same-target registration was
+ // already resolved by RegisterNavigationOutcome, making this TrySetResult a no-op; the
+ // store is only cleared when it still points at OUR awaiter, so a newer registration is
+ // never clobbered).
+ if (myOutcome is not null && (token.IsCancellationRequested || version != _navVersion))
+ {
+ if (ReferenceEquals(_pendingOutcome, myOutcome))
+ {
+ _pendingOutcome = null;
+ _pendingOutcomeUri = null;
+ }
+ myOutcome.TrySetResult(BrouterNavigationOutcome.Superseded());
+ }
+
// Dispose our CTS exactly when it can no longer be observed by any other path:
// - It's been superseded (a newer pipeline replaced _navCts), or
// - The Brouter has been disposed (Dispose() swapped _navCts out and disposed it).
@@ -644,21 +1980,365 @@ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLoca
}
}
+ /// The freshness window this route's loader results cache under, or null for "no caching".
+ private TimeSpan? EffectiveStaleTime(Broute node) => node.StaleTime ?? Options.DefaultLoaderStaleTime;
+
+ ///
+ /// Speculatively runs the loaders of the route chain that would match and
+ /// stores the results in the SWR cache, so an actual navigation there finds warm data (see
+ /// ). Guards do NOT run and nothing renders; loaders observe
+ /// . Nodes whose cache entry is still fresh are
+ /// skipped, so repeated hover-triggers don't refetch. Failures are swallowed - a speculative
+ /// fetch failing must neither surface error UI nor fire OnError; the real navigation will run
+ /// the loader again and handle the error through the normal pipeline.
+ ///
+ internal Task PreloadAsync(string url) => InvokeAsync(() => PreloadCoreAsync(url).AsTask());
+
+ private async ValueTask PreloadCoreAsync(string url)
+ {
+ BrouterLocation to;
+ try
+ {
+ to = ComputeLocation(_navManager.ToAbsoluteUri(url).ToString());
+ }
+ catch (Exception ex) when (ex is ArgumentException or UriFormatException or InvalidOperationException)
+ {
+ return; // malformed target; the real navigation will surface it properly
+ }
+
+ var match = SelectWinner(to);
+ if (match is null) return;
+
+ var chain = new List();
+ for (var node = match.Value.Route; node is not null; node = node.Parent) chain.Add(node);
+ chain.Reverse();
+
+ var ctx = new BrouterNavigationContext(CurrentLocation, to, CancellationToken.None)
+ {
+ NavigationType = BrouterNavigationType.Push,
+ IsPreload = true,
+ Route = match.Value.Route,
+ Parameters = new BrouterRouteParameters(match.Value.Parameters),
+ };
+
+ foreach (var node in chain)
+ {
+ if (node.Loader is null) continue;
+
+ var key = BrouterLoaderCache.MakeKey(node.FullTemplate, to);
+ // A still-fresh entry (from a previous preload or a committed navigation) needs no work.
+ if (_brouterService.LoaderCache.TryGet(
+ key, EffectiveStaleTime(node) ?? Options.PreloadStaleTime,
+ Options.PreloadStaleTime, Options.LoaderCacheGcTime,
+ out _, out var isStale) && isStale is false)
+ {
+ continue;
+ }
+
+ object? loaded;
+ try
+ {
+ loaded = await node.Loader(ctx);
+ }
+ catch
+ {
+ // Speculative fetch failed; the real navigation will re-run and report it.
+ continue;
+ }
+
+ CacheLoaderResult(node, to, loaded, fromPreload: true);
+ }
+ }
+
+ ///
+ /// Stores a loader result in the SWR cache. Navigation/revalidation results are only stored for
+ /// routes that participate in caching (an effective StaleTime); preload results always store,
+ /// marked so lookups judge them against .
+ ///
+ private void CacheLoaderResult(Broute node, BrouterLocation to, object? value, bool fromPreload = false)
+ {
+ if (fromPreload is false && EffectiveStaleTime(node) is null) return;
+ _brouterService.LoaderCache.Set(
+ BrouterLoaderCache.MakeKey(node.FullTemplate, to), value, Options.MaxLoaderCacheEntries, fromPreload);
+ }
+
+ ///
+ /// Runs the s of every currently committed route that the pending
+ /// navigation to would deactivate, leaf -> root (children veto before their
+ /// parents, mirroring Angular's CanDeactivate order). A route that stays matched under the new
+ /// URL - same route instance in both chains, e.g. only a parameter or a descendant changed - is
+ /// not "left" and its guard does not fire. Returns false when a guard cancelled/redirected (the
+ /// decision is on for the caller to apply) or the navigation was
+ /// superseded; true to continue the pipeline.
+ ///
+ private async ValueTask InvokeLeaveGuardsAsync(BrouterLocation to, BrouterNavigationContext ctx)
+ {
+ var committed = _committedChain;
+ if (committed.Length == 0) return true;
+
+ var anyLeaveGuard = false;
+ foreach (var node in committed)
+ {
+ if (node.LeaveGuard is not null) { anyLeaveGuard = true; break; }
+ }
+ if (anyLeaveGuard is false) return true;
+
+ // Which committed routes survive the new URL? Match it (SelectWinner is pure, so this is
+ // safe pre-commit) and collect the new chain; committed routes present in it are updated,
+ // not left. A null match means everything is being left.
+ HashSet? staying = null;
+ if (SelectWinner(to) is { } newMatch)
+ {
+ staying = [];
+ for (var node = newMatch.Route; node is not null; node = node.Parent) staying.Add(node);
+ }
+
+ for (var i = committed.Length - 1; i >= 0; i--)
+ {
+ var node = committed[i];
+ if (node.LeaveGuard is null) continue;
+ if (staying is not null && staying.Contains(node)) continue;
+
+ if (ctx.CancellationToken.IsCancellationRequested) return false;
+ // Expose the route being left for the duration of its own guard call only.
+ ctx.Route = node;
+ try
+ {
+ await node.LeaveGuard(ctx);
+ }
+ finally
+ {
+ ctx.Route = null;
+ }
+ if (ctx.CancellationToken.IsCancellationRequested) return false;
+ if (ctx.IsCancelled || ctx.RedirectUrl is not null) return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Routes a commit-phase navigation failure to the nearest error boundary: walking leaf -> root
+ /// from (the failed route, when known), the first route with an
+ /// renders it in place of its content - ancestor layouts above
+ /// the boundary keep rendering. With no route boundary in the chain, the router-level
+ /// renders instead. With neither, this is a no-op and the previous
+ /// page simply stays visible (the pre-boundary behavior). The
+ /// hook has already fired by the time this runs - boundaries are UI, not observability.
+ ///
+ private void RenderNavigationError(Broute? origin, BrouterNavigationContext ctx, Exception ex)
+ {
+ ResolveNavigationOutcome(ctx.To.FullUri, BrouterNavigationOutcome.Failed(ex));
+
+ var errorContext = new BrouterErrorContext(ex, ctx.To, this);
+
+ for (var node = origin; node is not null; node = node.Parent)
+ {
+ if (node.ErrorContent is not null)
+ {
+ node.CurrentError = errorContext;
+ // SetMatched marks node + ancestors and issues the render; node's renderer emits
+ // ErrorContent instead of Content/Component while CurrentError is set. Descendants
+ // of the boundary stay unmatched (the winner was never SetMatched on this path).
+ node.SetMatched();
+
+ // The boundary and its ancestors are what's on screen now; record them so a later
+ // navigation still runs their leave guards.
+ var chain = new List();
+ for (var n = node; n is not null; n = n.Parent) chain.Add(n);
+ chain.Reverse();
+ _committedChain = chain.ToArray();
+ return;
+ }
+ }
+
+ if (ErrorContent is not null)
+ {
+ _navError = errorContext;
+ _committedChain = [];
+ StateHasChanged();
+ }
+ }
+
+ ///
+ /// Re-runs the full navigation pipeline (guards, loaders, render) for the current URL. Exposed
+ /// to error boundaries via . Runs as a fresh
+ /// full-pipeline pass - guards deliberately re-run, since a retried navigation must re-establish
+ /// the same authorization a first attempt would.
+ ///
+ internal Task RetryNavigationAsync()
+ {
+ var to = ComputeLocation();
+ // Replace, not Push: the retry re-processes the entry the user is already on.
+ return InvokeAsync(() => ProcessNavigationAsync(CurrentLocation, to, decisionAlreadyMade: false, BrouterNavigationType.Replace).AsTask());
+ }
+
+ ///
+ /// Re-runs the loaders of the currently committed route chain and re-renders with the fresh
+ /// data - the router-level primitive for "a mutation happened, refresh what's on screen"
+ /// (React Router's revalidation / SvelteKit's invalidateAll). Not a navigation: the URL is
+ /// unchanged, guards and OnNavigating/OnNavigated do not run, and the current content stays
+ /// visible while loaders work (stale-while-revalidate, no pending-UI flash). Participates in
+ /// the navigation supersession machinery, so a real navigation starting mid-revalidate cancels
+ /// it (and vice versa an in-flight navigation's loaders are superseded by the revalidate).
+ /// Loader failures route to error boundaries and OnError exactly like navigation loads.
+ ///
+ internal Task RevalidateAsync() => InvokeAsync(() => RevalidateCoreAsync().AsTask());
+
+ private async ValueTask RevalidateCoreAsync()
+ {
+ var chain = _committedChain;
+ if (chain.Length == 0) return;
+
+ var anyLoader = false;
+ foreach (var node in chain)
+ {
+ if (node.Loader is not null) { anyLoader = true; break; }
+ }
+ if (anyLoader is false) return;
+
+ var to = CurrentLocation;
+ var leaf = chain[^1];
+
+ // Same supersession discipline as ProcessNavigationAsync: bumping the version and swapping
+ // the CTS makes this revalidate cancel an in-flight navigation's awaits, and makes any
+ // navigation that starts later cancel this revalidate.
+ var version = Interlocked.Increment(ref _navVersion);
+ var newCts = new CancellationTokenSource();
+ var oldCts = Interlocked.Exchange(ref _navCts, newCts);
+ oldCts?.Cancel();
+ var token = newCts.Token;
+
+ var ctx = new BrouterNavigationContext(to, to, token)
+ {
+ // The history entry is untouched; Replace is the closest classification and
+ // IsRevalidation is the authoritative signal for loader logic.
+ NavigationType = BrouterNavigationType.Replace,
+ IsRevalidation = true,
+ Route = leaf,
+ Parameters = new BrouterRouteParameters(leaf.Parameters),
+ };
+ var service = _brouterService;
+
+ try
+ {
+ // Collect the loaders up front; results commit only after each completes so the screen
+ // keeps showing the previous data until fresh data is actually available.
+ List pendingLoaders = [];
+ foreach (var node in chain)
+ {
+ if (node.Loader is not null) pendingLoaders.Add(node);
+ }
+
+ if (ParallelLoaders && pendingLoaders.Count > 1)
+ {
+ var loaderTasks = new Task<(object? Result, Exception? Error)>[pendingLoaders.Count];
+ for (var i = 0; i < loaderTasks.Length; i++)
+ {
+ loaderTasks[i] = RunLoaderAsync(pendingLoaders[i]);
+ }
+
+ var results = await Task.WhenAll(loaderTasks);
+
+ async Task<(object? Result, Exception? Error)> RunLoaderAsync(Broute node)
+ {
+ try { return (await node.Loader!(ctx), null); }
+ catch (Exception ex) { return (null, ex); }
+ }
+
+ for (var i = 0; i < results.Length; i++)
+ {
+ var (loaded, error) = results[i];
+ if (error is OperationCanceledException && token.IsCancellationRequested) return;
+ if (error is not null)
+ {
+ await service.InvokeOnError(ctx, error);
+ if (token.IsCancellationRequested is false && version == _navVersion)
+ {
+ RenderNavigationError(pendingLoaders[i], ctx, error);
+ }
+ return;
+ }
+ if (token.IsCancellationRequested || version != _navVersion) return;
+ pendingLoaders[i].LoadedData = loaded;
+ CacheLoaderResult(pendingLoaders[i], to, loaded);
+ }
+ }
+ else
+ {
+ foreach (var node in pendingLoaders)
+ {
+ object? loaded;
+ try
+ {
+ loaded = await node.Loader!(ctx);
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ await service.InvokeOnError(ctx, ex);
+ if (token.IsCancellationRequested is false && version == _navVersion)
+ {
+ RenderNavigationError(node, ctx, ex);
+ }
+ return;
+ }
+
+ if (token.IsCancellationRequested || version != _navVersion) return;
+ node.LoadedData = loaded;
+ CacheLoaderResult(node, to, loaded);
+ }
+ }
+
+ // Fresh LoadedData references make the renderers rebuild their BrouterRouteData wrappers,
+ // which re-notifies every cascading subscriber; one render request at the leaf covers the
+ // whole chain (see SetMatched).
+ leaf.SetMatched();
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ // superseded by a navigation; nothing to do
+ }
+ catch (Exception ex)
+ {
+ await service.InvokeOnError(ctx, ex);
+ if (token.IsCancellationRequested is false && version == _navVersion)
+ {
+ RenderNavigationError(leaf, ctx, ex);
+ }
+ }
+ finally
+ {
+ // Same CTS ownership rule as ProcessNavigationAsync's finally.
+ if (ReferenceEquals(Volatile.Read(ref _navCts), newCts) is false)
+ {
+ newCts.Dispose();
+ }
+ }
+ }
+
private bool HandleSideEffects(BrouterNavigationContext ctx, BrouterLocation from)
{
if (ctx.RedirectUrl is not null)
{
- _navManager.NavigateTo(ctx.RedirectUrl);
+ // Resolve before NavigateInternal for the same reason as ApplyPreventiveDecision: the
+ // redirect's pipeline must not observe (and supersede) the awaiter we're concluding.
+ ResolveNavigationOutcome(ctx.To.FullUri, BrouterNavigationOutcome.Redirected(ctx.RedirectUrl));
+ NavigateInternal(ctx.RedirectUrl);
return true;
}
if (ctx.IsCancelled)
{
+ ResolveNavigationOutcome(ctx.To.FullUri, BrouterNavigationOutcome.Cancelled());
// Restore the address bar. If From is empty (initial render), we leave the URL alone.
if (string.IsNullOrEmpty(from.FullUri) is false &&
string.Equals(from.FullUri, ctx.To.FullUri, StringComparison.Ordinal) is false)
{
- _navManager.NavigateTo(from.FullUri, replace: true);
+ NavigateInternal(from.FullUri, replace: true);
}
return true;
}
@@ -672,11 +2352,11 @@ private bool HandleSideEffects(BrouterNavigationContext ctx, BrouterLocation fro
///
private readonly struct MatchResult
{
- public BrouterRoute Route { get; }
+ public Broute Route { get; }
public Dictionary Parameters { get; }
public Dictionary ConstraintsByParameter { get; }
- public MatchResult(BrouterRoute route,
+ public MatchResult(Broute route,
Dictionary parameters,
Dictionary constraintsByParameter)
{
@@ -686,7 +2366,73 @@ public MatchResult(BrouterRoute route,
}
}
- private bool TryMatch(BrouterRoute route, string[] segments, bool hasTrailingSlash, out MatchResult result)
+ ///
+ /// Matches against the registered routes and returns the winning
+ /// , or null when nothing matches. Pure: never mutates a route (in
+ /// particular it does not touch Broute.Matched), so it is safe to call from the preventive
+ /// changing phase (where the current route is still rendered) as well as the commit phase. Both
+ /// phases run identical selection, so an approved changing decision and its commit agree on the winner.
+ ///
+ private MatchResult? SelectWinner(BrouterLocation to)
+ {
+ var index = GetRouteIndex();
+ var segments = to.SegmentsArray;
+
+ MatchResult best = default;
+ var bestOrder = 0;
+ var haveBest = false;
+
+ // Only routes whose first template segment can match the URL's first segment are viable: the
+ // bucket keyed on that segment, plus every route that doesn't start with a fixed literal.
+ // This is what keeps matching off a full O(routes) scan when routes number in the hundreds.
+ if (segments.Length > 0 &&
+ index.LiteralBuckets.TryGetValue(segments[0], out var literalEntries))
+ {
+ foreach (var entry in literalEntries)
+ ConsiderCandidate(entry, to, segments, ref best, ref bestOrder, ref haveBest);
+ }
+
+ foreach (var entry in index.NonLiteralFirst)
+ ConsiderCandidate(entry, to, segments, ref best, ref bestOrder, ref haveBest);
+
+ return haveBest ? best : null;
+ }
+
+ ///
+ /// Runs for one candidate and, on a match, keeps it as the running best when
+ /// it beats the current one. Ranking: most specific wins; ties broken by deeper nesting (so an
+ /// index child wins over its parent when their full templates are identical), then by index-route
+ /// preference, then by earliest registration order. The explicit order tiebreak reproduces the old
+ /// full-scan behavior (which kept the first candidate on a tie) regardless of the order the index
+ /// visits routes in. Exact-duplicate templates are rejected at registration (see
+ /// ), so the order tiebreak only ever arbitrates between distinct
+ /// templates whose match sets merely overlap for this particular URL (e.g. "/a/{x:int}" vs "/a/{x:min(0)}").
+ ///
+ private void ConsiderCandidate(in RouteEntry entry, BrouterLocation to, string[] segments,
+ ref MatchResult best, ref int bestOrder, ref bool haveBest)
+ {
+ if (TryMatch(entry.Route, segments, to.HasTrailingSlash, out var result) is false) return;
+
+ if (haveBest is false)
+ {
+ best = result;
+ bestOrder = entry.Order;
+ haveBest = true;
+ return;
+ }
+
+ int cmp = result.Route.Specificity - best.Route.Specificity;
+ if (cmp == 0) cmp = result.Route.Depth - best.Route.Depth;
+ if (cmp == 0) cmp = (result.Route.IsIndex ? 1 : 0) - (best.Route.IsIndex ? 1 : 0);
+ if (cmp == 0) cmp = bestOrder - entry.Order; // earlier registration wins an otherwise exact tie
+ if (cmp > 0)
+ {
+ best = result;
+ bestOrder = entry.Order;
+ }
+ }
+
+ private bool TryMatch(Broute route, string[] segments, bool hasTrailingSlash, out MatchResult result)
{
result = default;
@@ -714,9 +2460,19 @@ private bool TryMatch(BrouterRoute route, string[] segments, bool hasTrailingSla
// Under Options.IgnoreTrailingSlash == false a URL ending in '/' is distinct from one
// that doesn't. Templates are always normalized via TemplateParser to drop trailing
// slashes, so a non-catch-all route can never legitimately require the slash and must
- // not match a trailing-slash URL. Catch-all is exempt: it absorbs the trailing position
- // (matching zero or more remaining segments, including the implicit empty one).
- if (hasTrailingSlash && last.IsCatchAll is false) return false;
+ // not match a trailing-slash URL. Two exceptions absorb the trailing position:
+ // - Catch-all: it matches zero or more remaining segments, including the implicit
+ // empty one.
+ // - An optional final segment left unfilled by the URL: the trailing slash stands in
+ // for that empty optional value (e.g. "/users/" against "/users/{id?}"). This only
+ // applies while the optional segment is genuinely unfilled - i.e. the URL is shorter
+ // than the template. Once the template is fully satisfied a trailing slash is a real
+ // extra slash ("/users/1/" against "/users/{id?}") and must still be rejected.
+ if (hasTrailingSlash && last.IsCatchAll is false
+ && (last.IsOptional is false || segments.Length >= templateSegments.Count))
+ {
+ return false;
+ }
if (templateSegments.Count != segments.Length)
{
@@ -798,6 +2554,18 @@ public void Dispose()
_disposed = true;
_navManager.LocationChanged -= NavManagerLocationChanged;
+#if NET10_0_OR_GREATER
+ _navManager.OnNotFound -= NavManagerOnNotFound;
+#endif
+ // Unhook the preventive changing handler so a disposed Brouter can't keep vetoing navigations.
+ _locationChangingRegistration?.Dispose();
+ _locationChangingRegistration = null;
+ // Unsubscribe the prerender persistence callback so a disposed Brouter isn't asked to persist.
+ if (_persistSubscribed)
+ {
+ _persistSubscription.Dispose();
+ _persistSubscribed = false;
+ }
// Detach the active CTS and cancel it, but DON'T dispose here. A still-running
// ProcessNavigationAsync may be observing this CTS via its `token` parameter or
// about to throw OperationCanceledException through it; disposing now would race
@@ -807,6 +2575,11 @@ public void Dispose()
// paths reach disposal, the second call is a no-op.
var cts = Interlocked.Exchange(ref _navCts, null);
cts?.Cancel();
+ // Never leave a NavigateAsync caller hanging on a disposed router.
+ var pendingOutcome = _pendingOutcome;
+ _pendingOutcome = null;
+ _pendingOutcomeUri = null;
+ pendingOutcome?.TrySetResult(BrouterNavigationOutcome.Superseded());
_brouterService.Detach(this);
}
diff --git a/src/Brouter/Bit.Brouter/BrouterAwait.cs b/src/Brouter/Bit.Brouter/BrouterAwait.cs
new file mode 100644
index 0000000000..7c101342b3
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouterAwait.cs
@@ -0,0 +1,106 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace Bit.Brouter;
+
+///
+/// Renders deferred (streamed) data: give it a your route
+/// returned unawaited inside its result object, and the route reveals immediately while this
+/// component shows , then (or ) when the
+/// task settles. The router-level equivalent of React Router's <Await> / TanStack Router's
+/// deferred data: critical data blocks navigation via the loader, slow below-the-fold data streams in.
+///
+///
+/// Pattern: the loader returns quickly with the slow parts as unawaited tasks -
+/// return new PostData(post: await FetchPost(...), comments: FetchComments(...)) - and the page
+/// renders <BrouterAwait Task="@Data.Get<PostData>().Comments">...</BrouterAwait>.
+/// Note that loader results containing live tasks are skipped by
+/// (tasks aren't serializable), so such loaders re-run on the interactive pass - by design.
+///
+public sealed class BrouterAwait : ComponentBase, IDisposable
+{
+ /// The deferred task to await. A new task reference restarts the pending/resolved cycle.
+ [Parameter, EditorRequired] public Task? Task { get; set; }
+
+ /// Shown while the task is running (and when is null).
+ [Parameter] public RenderFragment? Pending { get; set; }
+
+ /// Shown when the task completes successfully; receives the result.
+ [Parameter] public RenderFragment? Resolved { get; set; }
+
+ ///
+ /// Shown when the task faults (or is cancelled - it receives the ).
+ /// When omitted, a faulted task renders nothing; the failure stays observable on the task itself.
+ ///
+ [Parameter] public RenderFragment? Error { get; set; }
+
+ // The task instance this component is currently observing. Guards against a superseded task
+ // (route data refreshed mid-await) applying its completion render over the newer task's state.
+ private Task? _observed;
+
+ // Set on disposal so an in-flight ObserveAsync resuming afterwards never schedules a render.
+ private bool _disposed;
+
+ protected override void OnParametersSet()
+ {
+ if (ReferenceEquals(_observed, Task)) return;
+
+ _observed = Task;
+ if (Task is { IsCompleted: false })
+ {
+ _ = ObserveAsync(Task);
+ }
+ }
+
+ private async Task ObserveAsync(Task task)
+ {
+ try
+ {
+ await task;
+ }
+ catch
+ {
+ // The failure renders via the task's own status below; awaiting here only observes the
+ // exception so it never surfaces as an unobserved-task crash.
+ }
+
+ if (_disposed is false && ReferenceEquals(_observed, task))
+ {
+ await InvokeAsync(StateHasChanged);
+ }
+ }
+
+ public void Dispose()
+ {
+ _disposed = true;
+ }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ base.BuildRenderTree(builder);
+
+ var task = Task;
+ if (task is null || task.IsCompleted is false)
+ {
+ if (Pending is not null) builder.AddContent(0, Pending);
+ return;
+ }
+
+ if (task.IsCompletedSuccessfully)
+ {
+ if (Resolved is not null) builder.AddContent(1, Resolved(task.Result));
+ return;
+ }
+
+ // Faulted or cancelled.
+ if (Error is not null)
+ {
+ Exception exception = task.IsCanceled
+ ? new TaskCanceledException(task)
+ : task.Exception!.InnerExceptions.Count == 1
+ ? task.Exception.InnerExceptions[0]
+ : task.Exception;
+ builder.AddContent(2, Error(exception));
+ }
+ }
+}
diff --git a/src/Brouter/Bit.Brouter/BrouterConstraintRegistry.cs b/src/Brouter/Bit.Brouter/BrouterConstraintRegistry.cs
new file mode 100644
index 0000000000..09a4ff3ee0
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouterConstraintRegistry.cs
@@ -0,0 +1,95 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+
+namespace Bit.Brouter;
+
+///
+/// A per-container set of route parameter constraints. One instance lives on
+/// , so its scope is the DI container that owns the options -
+/// custom constraints registered here are visible only to the app/service provider that registered
+/// them, which isolates separate apps in one process (and parallel test classes) from one another.
+///
+///
+///
+/// Built-in constraints (int, bool, guid, long, float,
+/// double, decimal, datetime) are always available: they are stateless singletons
+/// shared across every registry, so they need no registration and cannot be overridden.
+///
+///
+/// Register custom constraints once during application startup, before any route is parsed:
+/// builder.Services.AddBitBrouterServices(o => o.Constraints.Register("slug", new SlugConstraint())).
+/// Each registered instance is cached and reused across all route matches (and across threads);
+/// implementations must be stateless and thread-safe.
+///
+///
+/// Multi-tenancy note. The scope is the DI container, not the tenant. This isolates separate
+/// apps/service providers (and test classes) from one another; it gives per-tenant isolation only if
+/// each tenant already owns a distinct container.
+///
+///
+public sealed class BrouterConstraintRegistry
+{
+ // Built-in constraints are stateless singletons, safe to share across every registry and thread,
+ // so they live in one shared immutable table rather than being copied into each instance.
+ private static readonly IReadOnlyDictionary _builtIns = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["int"] = new BrouterTypeRouteConstraint((string s, out int r) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out r)),
+ ["bool"] = new BrouterTypeRouteConstraint(bool.TryParse),
+ ["guid"] = new BrouterTypeRouteConstraint(Guid.TryParse),
+ ["long"] = new BrouterTypeRouteConstraint((string s, out long r) => long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out r)),
+ ["float"] = new BrouterTypeRouteConstraint((string s, out float r) => float.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out r)),
+ ["double"] = new BrouterTypeRouteConstraint((string s, out double r) => double.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out r)),
+ ["decimal"] = new BrouterTypeRouteConstraint((string s, out decimal r) => decimal.TryParse(s, NumberStyles.Number, CultureInfo.InvariantCulture, out r)),
+ ["datetime"] = new BrouterTypeRouteConstraint((string s, out DateTime r) => DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out r)),
+ };
+
+ private readonly ConcurrentDictionary _custom = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Registers a custom constraint for this container. Templates can then use
+ /// {name:yourConstraintName}. Throws if is a built-in constraint
+ /// or is already registered on this registry. Thread-safe.
+ ///
+ ///
+ /// The provided is cached and shared across every route match.
+ /// Implementations must be stateless and safe for concurrent use.
+ ///
+ public void Register(string name, BrouterRouteConstraint constraint)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentNullException.ThrowIfNull(constraint);
+
+ if (_builtIns.ContainsKey(name))
+ throw new InvalidOperationException($"'{name}' is a built-in constraint and cannot be overridden.");
+
+ if (_custom.TryAdd(name, constraint) is false)
+ throw new InvalidOperationException($"A constraint named '{name}' is already registered.");
+ }
+
+ ///
+ /// Removes a previously registered custom constraint from this container. Built-ins cannot be
+ /// removed. Returns true if a custom constraint was removed. Thread-safe.
+ ///
+ public bool Unregister(string name)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ return _custom.TryRemove(name, out _);
+ }
+
+ ///
+ /// Resolves a constraint by name: this container's custom constraints first, then the shared
+ /// built-ins. Returns null when the name is unknown.
+ ///
+ internal BrouterRouteConstraint? Create(string name) =>
+ _custom.TryGetValue(name, out var constraint) ? constraint
+ : _builtIns.TryGetValue(name, out var builtIn) ? builtIn
+ : null;
+
+ ///
+ /// Resolves a built-in constraint by name, used when no per-container registry is threaded through
+ /// parsing (e.g. a direct
+ /// call). Returns null when the name is not a built-in.
+ ///
+ internal static BrouterRouteConstraint? CreateBuiltIn(string name) =>
+ _builtIns.TryGetValue(name, out var builtIn) ? builtIn : null;
+}
diff --git a/src/Brouter/Bit.Brouter/BrouterConstraints.cs b/src/Brouter/Bit.Brouter/BrouterConstraints.cs
deleted file mode 100644
index df91a127ec..0000000000
--- a/src/Brouter/Bit.Brouter/BrouterConstraints.cs
+++ /dev/null
@@ -1,73 +0,0 @@
-using System.Collections.Concurrent;
-using System.Globalization;
-
-namespace Bit.Brouter;
-
-///
-/// Registry of route parameter constraints. Built-in constraints are always registered;
-/// custom constraints can be added via .
-///
-///
-///
-/// Each registered instance is cached and reused across all
-/// route matches (and across threads). Implementations must therefore be stateless and
-/// thread-safe.
-///
-///
-/// Process scope. The registry is a process-wide static. On Blazor Server every circuit
-/// observes the same set, and on parallel test runs (e.g. dotnet test with multi-target
-/// frameworks or per-class parallelism) registrations can race. Register custom constraints
-/// once during application startup, before any route is parsed; in tests, prefer
-/// in [TestCleanup] to keep test classes independent.
-///
-///
-public static class BrouterConstraints
-{
- private static readonly ConcurrentDictionary _constraints = new(StringComparer.OrdinalIgnoreCase)
- {
- ["int"] = new BrouterTypeRouteConstraint((string s, out int r) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out r)),
- ["bool"] = new BrouterTypeRouteConstraint(bool.TryParse),
- ["guid"] = new BrouterTypeRouteConstraint(Guid.TryParse),
- ["long"] = new BrouterTypeRouteConstraint((string s, out long r) => long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out r)),
- ["float"] = new BrouterTypeRouteConstraint((string s, out float r) => float.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out r)),
- ["double"] = new BrouterTypeRouteConstraint((string s, out double r) => double.TryParse(s, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out r)),
- ["decimal"] = new BrouterTypeRouteConstraint((string s, out decimal r) => decimal.TryParse(s, NumberStyles.Number, CultureInfo.InvariantCulture, out r)),
- ["datetime"] = new BrouterTypeRouteConstraint((string s, out DateTime r) => DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out r)),
- };
-
- ///
- /// Registers a custom constraint. Templates can then use {name:yourConstraintName}.
- /// Throws if is already registered. Thread-safe.
- ///
- ///
- /// The provided is cached and shared across every route match.
- /// Implementations must be stateless and safe for concurrent use.
- ///
- public static void Register(string name, BrouterRouteConstraint constraint)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(name);
- ArgumentNullException.ThrowIfNull(constraint);
-
- if (_constraints.TryAdd(name, constraint) is false)
- throw new InvalidOperationException($"A constraint named '{name}' is already registered.");
- }
-
- private static readonly HashSet _builtIns = new(StringComparer.OrdinalIgnoreCase)
- {
- "int", "bool", "guid", "long", "float", "double", "decimal", "datetime"
- };
-
- /// Removes a previously registered constraint. Built-ins cannot be removed. Thread-safe.
- public static bool Unregister(string name)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(name);
-
- if (_builtIns.Contains(name)) return false;
-
- var removed = _constraints.TryRemove(name, out _);
- return removed;
- }
-
- internal static BrouterRouteConstraint? Create(string name) =>
- _constraints.TryGetValue(name, out var constraint) ? constraint : null;
-}
diff --git a/src/Brouter/Bit.Brouter/BrouterErrorContext.cs b/src/Brouter/Bit.Brouter/BrouterErrorContext.cs
new file mode 100644
index 0000000000..c696ae3f2f
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouterErrorContext.cs
@@ -0,0 +1,37 @@
+using System.Threading.Tasks;
+
+namespace Bit.Brouter;
+
+///
+/// The context handed to an ErrorContent fragment ( or
+/// ) when a navigation fails in the commit phase - typically a
+/// route throwing. Carries the failure and the location it happened
+/// for, plus to re-run the failed navigation in place.
+/// Inspired by React Router's ErrorBoundary/useRouteError and SvelteKit's
+/// +error.svelte hierarchy.
+///
+public sealed class BrouterErrorContext
+{
+ private readonly Brouter _brouter;
+
+ internal BrouterErrorContext(Exception exception, BrouterLocation location, Brouter brouter)
+ {
+ Exception = exception;
+ Location = location;
+ _brouter = brouter;
+ }
+
+ /// The exception that failed the navigation.
+ public Exception Exception { get; }
+
+ /// The location whose navigation failed (the URL the user is at).
+ public BrouterLocation Location { get; }
+
+ ///
+ /// Re-runs the full navigation pipeline for the current URL - guards, loaders and render - so a
+ /// transient failure (e.g. a flaky fetch inside a ) can be retried
+ /// without leaving the page. A successful retry replaces the error UI with the routed content;
+ /// another failure re-renders the error UI with the new exception.
+ ///
+ public Task RetryAsync() => _brouter.RetryNavigationAsync();
+}
diff --git a/src/Brouter/Bit.Brouter/BrouterKeepAliveContext.cs b/src/Brouter/Bit.Brouter/BrouterKeepAliveContext.cs
new file mode 100644
index 0000000000..369ad4265e
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouterKeepAliveContext.cs
@@ -0,0 +1,28 @@
+namespace Bit.Brouter;
+
+///
+/// Cascaded to a route's content so the rendered component can tell
+/// whether it is the currently visible route or is being kept mounted (hidden) after the user
+/// navigated away. Consume it with [CascadingParameter] and branch in OnParametersSet
+/// to pause background work (timers, polling, live subscriptions) while inactive and resume or
+/// refresh on reactivation - the keep-alive equivalent of Vue's onActivated/onDeactivated
+/// and Angular's route-reuse hooks.
+///
+///
+/// A fresh instance is cascaded each time the active state flips (the cascade is not fixed), so a
+/// component that reads in OnParametersSet is re-invoked on every
+/// activate/deactivate transition. While a route is kept but hidden its component stays fully alive
+/// and keeps running unless it chooses to pause when is false.
+/// Permanent teardown - the route being removed, or dropping
+/// the retained instance - surfaces through normal component disposal (IDisposable.Dispose).
+///
+public sealed class BrouterKeepAliveContext
+{
+ ///
+ /// true when this is the currently matched, visible route; false while the
+ /// component is kept mounted but hidden after navigating away.
+ ///
+ public bool IsActive { get; }
+
+ internal BrouterKeepAliveContext(bool isActive) => IsActive = isActive;
+}
diff --git a/src/Brouter/Bit.Brouter/BrouterLink.cs b/src/Brouter/Bit.Brouter/BrouterLink.cs
index 91c8f661be..2e1942a364 100644
--- a/src/Brouter/Bit.Brouter/BrouterLink.cs
+++ b/src/Brouter/Bit.Brouter/BrouterLink.cs
@@ -32,7 +32,13 @@ public sealed class BrouterLink : ComponentBase, IAsyncDisposable
[Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? AdditionalAttributes { get; set; }
- /// The destination URL or path.
+ ///
+ /// The destination URL or path. A route-relative path (./x, ../x) is resolved
+ /// against the current location using segment math (from /users/42, "./edit"
+ /// points at /users/42/edit and "../7" at /users/7); the anchor's
+ /// rendered href is the resolved absolute path and is re-resolved after every
+ /// navigation. Bare paths without a leading . keep their base-relative meaning.
+ ///
[Parameter, EditorRequired] public string Href { get; set; } = "/";
/// Inner content of the link.
@@ -55,12 +61,61 @@ public sealed class BrouterLink : ComponentBase, IAsyncDisposable
///
[Parameter] public bool Replace { get; set; }
+ ///
+ /// When (if ever) this link preloads its destination's loader data into the router's cache so
+ /// the actual navigation finds warm data: on interaction
+ /// (hover/touch/focus, debounced by ), when scrolled
+ /// into view, or at
+ /// time. Null (the default) falls back to .
+ /// Preloads run loaders only - no guards, no rendering; see
+ /// .
+ ///
+ [Parameter] public BrouterLinkPreload? Preload { get; set; }
+
+ ///
+ /// Optional application state to attach to the destination's history entry when this link is
+ /// clicked (see ). Read it back on
+ /// after the navigation - including when the user
+ /// later returns to the entry via Back/Forward. Setting this makes the link intercept
+ /// unmodified left-clicks the same way does (an href-driven navigation
+ /// cannot carry history state); modified clicks keep their native browser behavior, in which
+ /// case the state is not attached (a new tab is a fresh history stack anyway).
+ ///
+ [Parameter] public string? HistoryState { get; set; }
+
private bool _isActive;
+ // Href with any route-relative prefix ("./", "../") resolved against the current location.
+ // Equals Href verbatim for absolute/base-relative hrefs. This is what gets rendered into
+ // the anchor, navigated to on click, and matched for the active state, so all three always
+ // agree. Recomputed in UpdateActiveState; re-rendered when navigation changes it.
+ private string _resolvedHref = "/";
private ElementReference _anchor;
+ // Fallback module import owned by THIS link, used only when Brouter isn't the shipped
+ // BrouterService (a custom IBrouter implementation). The normal path shares the scope's
+ // single module via BrouterService.GetModuleAsync, so a page full of Replace links costs
+ // one interop import instead of one per link. Only this fallback is disposed here; the
+ // shared module belongs to the service.
private IJSObjectReference? _module;
private IJSObjectReference? _handle;
- private bool _replaceWired;
+ private bool _interceptWired;
+
+ // Replace and HistoryState both require Brouter (not href-driven NavigationInterception) to
+ // perform the navigation, so both use the same conditional-preventDefault click interception.
+ private bool NeedsClickInterception => Replace || HistoryState is not null;
+
+ private BrouterLinkPreload EffectivePreload => Preload ?? Options.DefaultLinkPreload;
+
+ // Intent/Viewport need DOM listeners; Render fires straight from OnAfterRenderAsync.
+ private bool NeedsPreloadWiring =>
+ EffectivePreload is BrouterLinkPreload.Intent or BrouterLinkPreload.Viewport;
+
+ // JS handle for the preload trigger wiring (separate from the click-interception handle) and
+ // the .NET reference its callbacks target.
+ private IJSObjectReference? _preloadHandle;
+ private DotNetObjectReference? _selfRef;
+ private bool _preloadWired;
+ private bool _renderPreloadFired;
// UpdateActiveState memoisation. UpdateActiveState runs on every render of every link
// (OnParametersSet) and on every successful navigation (OnNavigated). For pages with many
@@ -89,12 +144,15 @@ protected override void OnParametersSet()
private ValueTask OnNavigated(BrouterNavigationContext ctx)
{
- var was = _isActive;
+ var wasActive = _isActive;
+ var wasHref = _resolvedHref;
UpdateActiveState();
// Return the InvokeAsync task wrapped as a ValueTask so any exception thrown by the
// re-render flows up the OnNavigated invocation chain instead of becoming an unobserved
- // task. ValueTask.CompletedTask is correct when nothing changed.
- return _isActive == was
+ // task. ValueTask.CompletedTask is correct when nothing changed. A changed resolved
+ // href (a route-relative Href pointing somewhere new after this navigation) needs a
+ // re-render even when the active flag didn't move, so the DOM href stays current.
+ return _isActive == wasActive && string.Equals(_resolvedHref, wasHref, StringComparison.Ordinal)
? ValueTask.CompletedTask
: new ValueTask(InvokeAsync(StateHasChanged));
}
@@ -117,9 +175,14 @@ private void UpdateActiveState()
return;
}
- // Recompute. Target only needs renormalising when Href actually changed.
+ // Recompute. A route-relative Href depends on the current path, so resolve it first;
+ // for absolute/base-relative hrefs this is Href verbatim.
+ var resolvedHref = BrouterRelativeUrl.ResolveIfRelative(current, Href);
+
+ // Target only needs renormalising when the resolved href actually changed (value
+ // comparison, not reference: the resolution may rebuild an equal string).
string target;
- if (_cachedTarget is not null && ReferenceEquals(_cachedHref, Href))
+ if (_cachedTarget is not null && string.Equals(_resolvedHref, resolvedHref, StringComparison.Ordinal))
{
target = _cachedTarget;
}
@@ -129,19 +192,26 @@ private void UpdateActiveState()
// Options.IgnoreTrailingSlash is true, so we must mirror that here when normalising
// the link's Href. Otherwise BrouterLinkMatch.All would never match a current path
// that legitimately ends in '/' under Options.IgnoreTrailingSlash == false.
- target = NormalisePath(Href, stripTrailingSlash: Options.IgnoreTrailingSlash);
+ target = NormalisePath(resolvedHref, stripTrailingSlash: Options.IgnoreTrailingSlash);
_cachedTarget = target;
}
+ _resolvedHref = resolvedHref;
var comparison = Options.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
_isActive = Match switch
{
BrouterLinkMatch.All => string.Equals(current, target, comparison),
+ // The root "/" prefix-matches every path (everything starts with '/'), so a "home"
+ // link would light up on every page — the classic NavLink footgun. Match the root
+ // exactly even under Prefix, mirroring React Router's NavLink (a link to "/" is only
+ // active at the root). Note both the old `target == "/"` clause AND the `target[^1] ==
+ // '/'` clause below would otherwise force the root to always match.
+ _ when target == "/" => string.Equals(current, target, comparison),
// Prefix match: when target retains a trailing '/' (Options.IgnoreTrailingSlash == false
// and the link href ended with '/'), the slash itself enforces the segment boundary,
// so the explicit boundary check on current[target.Length] is unnecessary in that case.
_ => current.StartsWith(target, comparison) &&
- (current.Length == target.Length || target == "/" || target[^1] == '/' ||
+ (current.Length == target.Length || target[^1] == '/' ||
current[target.Length] == '/' || current[target.Length] == '?' || current[target.Length] == '#')
};
@@ -210,13 +280,13 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddMultipleAttributes(1, AdditionalAttributes.Where(kv => string.Equals(kv.Key, "class", StringComparison.OrdinalIgnoreCase) is false).Select(kv => new KeyValuePair(kv.Key, kv.Value)));
}
}
- builder.AddAttribute(2, "href", Href);
+ builder.AddAttribute(2, "href", _resolvedHref);
if (combinedClass is not null) builder.AddAttribute(3, "class", combinedClass);
if (_isActive) builder.AddAttribute(4, "aria-current", "page");
- // For Replace=false we rely on Blazor's NavigationInterception (same as Microsoft's
- // NavLink) to drive navigation off the anchor's href.
- // For Replace=true we hook our own click handler in C# AND wire a JS capture-phase
+ // Without Replace/HistoryState we rely on Blazor's NavigationInterception (same as
+ // Microsoft's NavLink) to drive navigation off the anchor's href.
+ // For Replace/HistoryState we hook our own click handler in C# AND wire a JS capture-phase
// listener (see OnAfterRenderAsync) that conditionally calls preventDefault only for
// unmodified primary clicks. That way, modified clicks (Ctrl/Cmd+click, Shift+click)
// keep their native "open in new tab" / "open in new window" behavior; only plain
@@ -228,9 +298,12 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
// disconnect, or interop failure), letting the click bubble means NavigationInterception
// can still pick it up and perform an SPA push navigation as a graceful fallback,
// instead of falling all the way through to a full page load.
- if (Replace)
+ if (NeedsClickInterception)
{
builder.AddAttribute(5, "onclick", EventCallback.Factory.Create(this, OnClick));
+ }
+ if (NeedsClickInterception || NeedsPreloadWiring)
+ {
builder.AddElementReferenceCapture(6, capturedRef => _anchor = capturedRef);
}
@@ -244,29 +317,76 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
// to stay on the renderer's SynchronizationContext so subsequent JS interop calls and
// any state changes/StateHasChanged remain marshaled correctly (especially on Blazor
// Server, where leaving the renderer context can break interop/state updates).
- if (Replace && _replaceWired is false)
+ if (NeedsClickInterception && _interceptWired is false)
{
try
{
- _module ??= await JS.InvokeAsync(
- "import", "./_content/Bit.Brouter/BitBrouter.js");
- _handle = await _module.InvokeAsync(
+ var module = await GetModuleAsync();
+ _handle = await module.InvokeAsync(
"wireConditionalPreventDefault", _anchor);
- _replaceWired = true;
+ _interceptWired = true;
}
catch (JSDisconnectedException) { /* Circuit disconnected; nothing to wire. */ }
catch (JSException) { /* JS interop failure; falls back to default link behavior. */ }
+ // InvalidOperationException also covers ObjectDisposedException, thrown when the
+ // shared module is disposed during scope teardown while a link is still wiring.
catch (InvalidOperationException) { /* JS interop unavailable during pre-render. */ }
catch (TaskCanceledException) { /* Component disposed mid-call. */ }
}
- else if (Replace is false && _replaceWired)
+ else if (NeedsClickInterception is false && _interceptWired)
{
- // Replace switched off after wiring; tear the JS handler down.
+ // Replace/HistoryState switched off after wiring; tear the JS handler down.
await DisposeJsHandleAsync();
- _replaceWired = false;
+ _interceptWired = false;
+ }
+
+ // Preload wiring, independent of the click-interception handle above.
+ switch (EffectivePreload)
+ {
+ case BrouterLinkPreload.Render when _renderPreloadFired is false:
+ _renderPreloadFired = true;
+ // Fire-and-forget: preloading is speculative and must never block rendering.
+ _ = Brouter.PreloadAsync(_resolvedHref).AsTask();
+ break;
+
+ case BrouterLinkPreload.Intent or BrouterLinkPreload.Viewport when _preloadWired is false:
+ try
+ {
+ var module = await GetModuleAsync();
+ _selfRef ??= DotNetObjectReference.Create(this);
+ _preloadHandle = await module.InvokeAsync(
+ "wirePreload", _anchor,
+ EffectivePreload == BrouterLinkPreload.Intent ? "intent" : "viewport",
+ Options.PreloadDelay.TotalMilliseconds, _selfRef);
+ _preloadWired = true;
+ }
+ catch (JSDisconnectedException) { /* circuit disconnected; nothing to wire */ }
+ catch (JSException) { /* JS interop failure; preloading degrades to nothing */ }
+ catch (InvalidOperationException) { /* interop unavailable during pre-render */ }
+ catch (TaskCanceledException) { /* component disposed mid-call */ }
+ break;
}
}
+ /// JS-invoked when the wired preload trigger (intent/viewport) fires.
+ [JSInvokable]
+ public Task OnPreloadTriggered() => Brouter.PreloadAsync(_resolvedHref).AsTask();
+
+ private ValueTask GetModuleAsync()
+ {
+ // Prefer the scope-shared module owned by BrouterService: every Replace link on the
+ // page then reuses one import instead of paying an interop round-trip each. The
+ // per-link import only remains for custom IBrouter implementations, where no shared
+ // module exists.
+ if (Brouter is BrouterService service) return service.GetModuleAsync();
+
+ return ImportOwnModuleAsync();
+
+ async ValueTask ImportOwnModuleAsync() =>
+ _module ??= await JS.InvokeAsync(
+ "import", "./_content/Bit.Brouter/bit-brouter.js");
+ }
+
private void OnClick(MouseEventArgs e)
{
// Mirrors the JS-side filter so the C# logic agrees with what the browser is doing:
@@ -274,16 +394,18 @@ private void OnClick(MouseEventArgs e)
// browser opens the link natively, and we should not also push a replace navigation.
if (e.Button != 0 || e.CtrlKey || e.ShiftKey || e.AltKey || e.MetaKey) return;
- // Only issue the replace navigation when our JS preventDefault handler is installed.
+ // Only issue our own navigation when the JS preventDefault handler is installed.
// Otherwise Blazor's NavigationInterception will pick the click up as a regular push
// navigation (we no longer stopPropagation, so the document-level interceptor still
// sees the event), and adding our own NavigateTo here would result in double-navigation
// (two LocationChanged events / two ProcessNavigationAsync passes for one click).
- // Degrading to a push when wiring failed is the safer fallback than racing with the
- // built-in interceptor or forcing a full page load.
- if (_replaceWired is false) return;
+ // Degrading to a plain, state-less push when wiring failed is the safer fallback than
+ // racing with the built-in interceptor or forcing a full page load.
+ if (_interceptWired is false) return;
- Brouter.Navigate(Href, replace: true);
+ // Navigate to the resolved href (identical to Href for non-relative links) so the
+ // click goes exactly where the rendered anchor points.
+ Brouter.Navigate(_resolvedHref, replace: Replace, historyState: HistoryState);
}
private async ValueTask DisposeJsHandleAsync()
@@ -312,6 +434,25 @@ public async ValueTask DisposeAsync()
await DisposeJsHandleAsync();
+ if (_preloadHandle is not null)
+ {
+ try { await _preloadHandle.InvokeVoidAsync("dispose"); }
+ catch (JSDisconnectedException) { }
+ catch (JSException) { }
+ catch (InvalidOperationException) { }
+ catch (TaskCanceledException) { }
+
+ try { await _preloadHandle.DisposeAsync(); }
+ catch (JSDisconnectedException) { }
+ catch (JSException) { }
+ catch (InvalidOperationException) { }
+ catch (TaskCanceledException) { }
+
+ _preloadHandle = null;
+ }
+ _selfRef?.Dispose();
+ _selfRef = null;
+
if (_module is not null)
{
try { await _module.DisposeAsync(); }
diff --git a/src/Brouter/Bit.Brouter/BrouterLinkPreload.cs b/src/Brouter/Bit.Brouter/BrouterLinkPreload.cs
new file mode 100644
index 0000000000..94b4ae7040
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouterLinkPreload.cs
@@ -0,0 +1,23 @@
+namespace Bit.Brouter;
+
+///
+/// When a preloads its destination's loader data into the router's cache
+/// (see / ).
+///
+public enum BrouterLinkPreload
+{
+ /// No preloading (default).
+ None = 0,
+
+ ///
+ /// Preload on interaction intent: pointer hover / touchstart / keyboard focus, debounced by
+ /// so brushing past a link doesn't fetch.
+ ///
+ Intent = 1,
+
+ /// Preload when the link scrolls into the viewport (IntersectionObserver), once.
+ Viewport = 2,
+
+ /// Preload as soon as the link renders.
+ Render = 3,
+}
diff --git a/src/Brouter/Bit.Brouter/BrouterLoaderCache.cs b/src/Brouter/Bit.Brouter/BrouterLoaderCache.cs
new file mode 100644
index 0000000000..4634327dcd
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouterLoaderCache.cs
@@ -0,0 +1,93 @@
+namespace Bit.Brouter;
+
+///
+/// The scoped stale-while-revalidate store for route results (see
+/// / ), also fed
+/// by link preloading. Keys combine the route's full template with the URL's path+query, so every
+/// chain node caches independently per concrete URL. Entries expire fully after
+/// and the store is capped at
+/// (oldest-written evicted first).
+///
+///
+/// Owned by the scoped , so Blazor Server circuits are isolated from
+/// each other for free. All access happens on the renderer's single dispatcher (navigation pipeline,
+/// revalidation, preload commits are all dispatched there), mirroring the threading discipline of
+/// the rest of the router - no locking needed.
+///
+internal sealed class BrouterLoaderCache
+{
+ internal sealed class Entry
+ {
+ public object? Value;
+ public DateTime WrittenUtc;
+ // True when the entry was produced by a preload rather than a committed navigation/revalidate.
+ // Preload entries are readable even by routes with no StaleTime configured, using
+ // BrouterOptions.PreloadStaleTime as their freshness window.
+ public bool FromPreload;
+ }
+
+ private readonly Dictionary _entries = new(StringComparer.Ordinal);
+
+ internal static string MakeKey(string fullTemplate, BrouterLocation to) =>
+ $"{fullTemplate}|{to.Path}|{to.Query}";
+
+ ///
+ /// Looks up a cached loader result. is the route's effective
+ /// freshness window (null when the route doesn't cache - only preload-produced entries are
+ /// then eligible, judged against ). A hit older than
+ /// is dropped and reported as a miss.
+ ///
+ public bool TryGet(string key, TimeSpan? staleTime, TimeSpan preloadStaleTime, TimeSpan gcTime,
+ out object? value, out bool isStale)
+ {
+ value = null;
+ isStale = false;
+
+ if (_entries.TryGetValue(key, out var entry) is false) return false;
+
+ var age = DateTime.UtcNow - entry.WrittenUtc;
+ if (age > gcTime)
+ {
+ _entries.Remove(key);
+ return false;
+ }
+
+ var window = staleTime ?? (entry.FromPreload ? preloadStaleTime : (TimeSpan?)null);
+ if (window is null) return false;
+
+ value = entry.Value;
+ isStale = age > window.Value;
+ return true;
+ }
+
+ /// Stores/refreshes a loader result. Re-writing an existing key refreshes its timestamp.
+ public void Set(string key, object? value, int maxEntries, bool fromPreload = false)
+ {
+ // Delete-then-add keeps insertion order aligned with write recency, so the eviction scan
+ // below (oldest WrittenUtc) stays cheap in the common case and correct always.
+ _entries.Remove(key);
+
+ while (_entries.Count >= maxEntries && _entries.Count > 0)
+ {
+ // Evict the oldest-written entry. Linear scan is fine at the default cap (50).
+ string? oldestKey = null;
+ var oldestTime = DateTime.MaxValue;
+ foreach (var kv in _entries)
+ {
+ if (kv.Value.WrittenUtc < oldestTime)
+ {
+ oldestTime = kv.Value.WrittenUtc;
+ oldestKey = kv.Key;
+ }
+ }
+ if (oldestKey is null) break;
+ _entries.Remove(oldestKey);
+ }
+
+ _entries[key] = new Entry { Value = value, WrittenUtc = DateTime.UtcNow, FromPreload = fromPreload };
+ }
+
+ public void Clear() => _entries.Clear();
+
+ internal int Count => _entries.Count;
+}
diff --git a/src/Brouter/Bit.Brouter/BrouterLocation.cs b/src/Brouter/Bit.Brouter/BrouterLocation.cs
index c8e286b6a1..957435ab04 100644
--- a/src/Brouter/Bit.Brouter/BrouterLocation.cs
+++ b/src/Brouter/Bit.Brouter/BrouterLocation.cs
@@ -14,10 +14,11 @@ public sealed class BrouterLocation
private readonly Lazy>> _queryParams;
private readonly string[] _segments;
- internal BrouterLocation(string fullUri, string path, string[] segments, string query, string hash, bool hasTrailingSlash = false)
+ internal BrouterLocation(string fullUri, string path, string[] segments, string query, string hash, bool hasTrailingSlash = false, string? historyState = null)
{
FullUri = fullUri;
Path = path;
+ HistoryState = historyState;
if (segments is null || segments.Length == 0)
{
_segments = [];
@@ -61,6 +62,17 @@ internal BrouterLocation(string fullUri, string path, string[] segments, string
/// The fragment part including the leading '#'. Empty when absent.
public string Hash { get; }
+ ///
+ /// The application state attached to this location's history entry, or null when the entry
+ /// carries no state. Set by navigating with a historyState argument (see
+ /// or ); read back here
+ /// after the navigation commits - including on a later Back/Forward to this entry, where the
+ /// browser restores the stored value. Mirrors NavigationManager.HistoryEntryState
+ /// (backed by history.state), so the value survives history traversals but not a full
+ /// page reload on all browsers. Store a serialized payload (e.g. JSON) for structured data.
+ ///
+ public string? HistoryState { get; }
+
/// Parsed query parameters. Multiple values per key are supported.
public IReadOnlyDictionary> QueryParams => _queryParams.Value;
diff --git a/src/Brouter/Bit.Brouter/BrouterNavigationContext.cs b/src/Brouter/Bit.Brouter/BrouterNavigationContext.cs
index ac9ad22bdc..54f2e93a73 100644
--- a/src/Brouter/Bit.Brouter/BrouterNavigationContext.cs
+++ b/src/Brouter/Bit.Brouter/BrouterNavigationContext.cs
@@ -24,8 +24,33 @@ internal BrouterNavigationContext(BrouterLocation from, BrouterLocation to, Canc
/// Token cancelled when the navigation is superseded by a newer one.
public CancellationToken CancellationToken { get; }
+ ///
+ /// How this navigation was initiated - a fresh push, a history-entry replace, or a Back/Forward
+ /// traversal. Lets guards, loaders and hooks distinguish a Back navigation from a new push, which
+ /// scroll-restoration and analytics logic often needs. Populated before guards run, so it is
+ /// available throughout the whole navigation. See for the
+ /// detection caveats.
+ ///
+ public BrouterNavigationType NavigationType { get; internal set; } = BrouterNavigationType.Push;
+
+ ///
+ /// True when this context belongs to a revalidation ()
+ /// rather than a navigation: the URL did not change, guards did not re-run, and only the
+ /// matched chain's loaders are executing again. Lets a shared loader distinguish "the user
+ /// navigated here" from "the app asked for fresh data after a mutation".
+ ///
+ public bool IsRevalidation { get; internal set; }
+
+ ///
+ /// True when this context belongs to a speculative preload ( /
+ /// ) rather than a real navigation: no guards ran, nothing will
+ /// render, and the loader result only warms the cache. Loaders with side effects beyond fetching
+ /// (analytics, "viewed" markers) should skip them when this is set.
+ ///
+ public bool IsPreload { get; internal set; }
+
/// The matched route once matching has happened. Null in OnNavigating hooks.
- public BrouterRoute? Route { get; internal set; }
+ public Broute? Route { get; internal set; }
/// Parameters extracted from the matched route. Empty when no match yet.
public BrouterRouteParameters Parameters { get; internal set; } = BrouterRouteParameters.Empty;
@@ -44,7 +69,12 @@ public void Cancel()
RedirectUrl = null;
}
- /// Redirect to another URL instead of completing this navigation.
+ ///
+ /// Redirect to another URL instead of completing this navigation.
+ /// A route-relative (./x, ../x) is resolved against the
+ /// path of - the location being navigated to - using segment math, so a guard
+ /// on /admin/secret can redirect to "../login" to reach /admin/login.
+ ///
/// Thrown when is null.
/// Thrown when is empty or whitespace.
/// Thrown when the navigation has already been cancelled
@@ -54,6 +84,33 @@ public void Redirect(string url)
ArgumentException.ThrowIfNullOrWhiteSpace(url);
if (IsCancelled)
throw new InvalidOperationException("Cannot set a redirect on a cancelled navigation context. Call Redirect() before Cancel(), or do not cancel.");
- RedirectUrl = url;
+
+ var resolved = BrouterRelativeUrl.ResolveIfRelative(To.Path, url);
+
+ // A redirect to the exact location this navigation is already heading to is "continue",
+ // not a redirect. Honoring it would cancel this navigation and start an identical one,
+ // whose guards run again and redirect again - an infinite navigation loop. Treating it
+ // as a no-op makes guards like "always send anonymous users to /login" safe to write
+ // without a "unless we're already going to /login" clause.
+ if (IsCurrentTarget(resolved)) return;
+
+ RedirectUrl = resolved;
+ }
+
+ ///
+ /// Whether denotes the same location this navigation is heading to.
+ /// Compared against both the absolute URI and the base-relative path+query+hash forms, since a
+ /// redirect may be expressed either way.
+ ///
+ private bool IsCurrentTarget(string resolved)
+ {
+ if (string.Equals(resolved, To.FullUri, StringComparison.OrdinalIgnoreCase)) return true;
+
+ var pathQueryHash = To.Path + To.Query + To.Hash;
+ if (string.Equals(resolved, pathQueryHash, StringComparison.OrdinalIgnoreCase)) return true;
+
+ // Base-relative form without the leading slash ("users/1" vs "/users/1").
+ return resolved.Length > 0 && resolved[0] != '/' &&
+ string.Equals("/" + resolved, pathQueryHash, StringComparison.OrdinalIgnoreCase);
}
}
diff --git a/src/Brouter/Bit.Brouter/BrouterNavigationOutcome.cs b/src/Brouter/Bit.Brouter/BrouterNavigationOutcome.cs
new file mode 100644
index 0000000000..5304d3d288
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouterNavigationOutcome.cs
@@ -0,0 +1,57 @@
+namespace Bit.Brouter;
+
+/// How a navigation started with concluded.
+public enum BrouterNavigationStatus
+{
+ /// The navigation committed and the matched route rendered.
+ Succeeded = 0,
+
+ /// A guard or OnNavigating hook cancelled the navigation; the URL is unchanged (or restored).
+ Cancelled = 1,
+
+ /// A guard, hook or sent the navigation elsewhere; see .
+ Redirected = 2,
+
+ /// No route matched the target URL; the not-found handling (fallback content or NotFound redirect) took over.
+ NotFound = 3,
+
+ /// A guard or loader threw; see . Error boundaries / OnError observed it.
+ Failed = 4,
+
+ /// A newer navigation started before this one finished, or the router was disposed.
+ Superseded = 5,
+}
+
+///
+/// The result of an awaited navigation (), mirroring Vue
+/// Router's navigation failures: callers can branch on how the navigation actually ended instead
+/// of assuming it committed.
+///
+public readonly struct BrouterNavigationOutcome
+{
+ private BrouterNavigationOutcome(BrouterNavigationStatus status, string? redirectedTo, Exception? exception)
+ {
+ Status = status;
+ RedirectedTo = redirectedTo;
+ Exception = exception;
+ }
+
+ /// How the navigation concluded.
+ public BrouterNavigationStatus Status { get; }
+
+ /// The URL a redirecting guard/hook/route sent the navigation to, when is .
+ public string? RedirectedTo { get; }
+
+ /// The failure, when is .
+ public Exception? Exception { get; }
+
+ /// Convenience: true when is .
+ public bool Succeeded => Status == BrouterNavigationStatus.Succeeded;
+
+ internal static BrouterNavigationOutcome Success() => new(BrouterNavigationStatus.Succeeded, null, null);
+ internal static BrouterNavigationOutcome Cancelled() => new(BrouterNavigationStatus.Cancelled, null, null);
+ internal static BrouterNavigationOutcome Redirected(string? to) => new(BrouterNavigationStatus.Redirected, to, null);
+ internal static BrouterNavigationOutcome NotFound() => new(BrouterNavigationStatus.NotFound, null, null);
+ internal static BrouterNavigationOutcome Failed(Exception ex) => new(BrouterNavigationStatus.Failed, null, ex);
+ internal static BrouterNavigationOutcome Superseded() => new(BrouterNavigationStatus.Superseded, null, null);
+}
diff --git a/src/Brouter/Bit.Brouter/BrouterNavigationType.cs b/src/Brouter/Bit.Brouter/BrouterNavigationType.cs
new file mode 100644
index 0000000000..18b9f68a4c
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouterNavigationType.cs
@@ -0,0 +1,36 @@
+namespace Bit.Brouter;
+
+///
+/// How the current navigation was initiated. Exposed on
+/// so guards, loaders and hooks can tell a fresh push from a Back/Forward (which some scroll-restoration
+/// and analytics logic needs to special-case). Mirrors Vue Router's navigation type ('push'/'replace'/'pop').
+///
+public enum BrouterNavigationType
+{
+ ///
+ /// A new history entry was pushed. This covers intercepted link clicks, a programmatic
+ /// / without replace,
+ /// and internal redirects. The initial page load is also reported as .
+ ///
+ Push = 0,
+
+ ///
+ /// The current history entry was replaced rather than a new one pushed: a programmatic navigation
+ /// with replace: true (including links) or an internal
+ /// address-bar restore after a cancelled navigation.
+ ///
+ Replace = 1,
+
+ ///
+ /// A history traversal - browser Back/Forward, or /
+ /// . This is the "Back navigation" case scroll restoration and
+ /// analytics typically treat differently from a fresh navigation.
+ ///
+ ///
+ /// Detection relies on the navigation going through Brouter's own primitives (links, ).
+ /// A raw NavigationManager.NavigateTo call that bypasses is indistinguishable
+ /// from a history traversal at the framework level and will be reported as ; route
+ /// programmatic navigations through to be classified correctly.
+ ///
+ Pop = 2
+}
diff --git a/src/Brouter/Bit.Brouter/BrouterOptions.cs b/src/Brouter/Bit.Brouter/BrouterOptions.cs
index 19fabb7eed..f0e67dc4a8 100644
--- a/src/Brouter/Bit.Brouter/BrouterOptions.cs
+++ b/src/Brouter/Bit.Brouter/BrouterOptions.cs
@@ -22,4 +22,213 @@ public sealed class BrouterOptions
/// Defaults to .
///
public BrouterScrollMode ScrollBehavior { get; set; } = BrouterScrollMode.None;
+
+ ///
+ /// Whether a URL fragment scrolls its target element into view after a successful navigation
+ /// (e.g. navigating to /docs#install scrolls the #install element into view and
+ /// moves focus to it). When a fragment target is found it takes precedence over
+ /// . Only acts when the destination URL carries a fragment.
+ /// Defaults to true.
+ ///
+ public bool ScrollToFragment { get; set; } = true;
+
+ ///
+ /// Whether the scroll position of each page is remembered and restored when the user navigates
+ /// Back or Forward (a history pop), mirroring what native browsers and real SPA
+ /// routers (React Router's ScrollRestoration, Vue Router's scrollBehavior) do: returning
+ /// to a page lands the user where they left it instead of at the top.
+ ///
+ /// This composes with the other scroll options rather than replacing them. A new
+ /// (push/replace) navigation still uses (e.g. scroll to top); only a
+ /// Back/Forward navigation to a previously-visited URL restores its saved position. Precedence per
+ /// navigation: a resolved URL fragment (see ) wins; then, on a
+ /// Back/Forward with a remembered position, that position is restored; otherwise
+ /// applies.
+ ///
+ ///
+ /// Positions are keyed by absolute URL. By default they are kept in memory for the lifetime of the
+ /// page (they do not survive a full reload); set to persist them
+ /// in sessionStorage/localStorage so they survive reloads. Enabling this sets
+ /// history.scrollRestoration = "manual" so the browser's own restoration doesn't fight the
+ /// router's; it is left untouched when disabled. Defaults to false.
+ ///
+ ///
+ public bool RestoreScrollPosition { get; set; } = false;
+
+ ///
+ /// Where saved scroll positions are stored when is enabled.
+ /// Defaults to (in-memory only, lost on reload).
+ /// Use (recommended) or
+ /// to persist positions so a reload returns
+ /// the user to where they left off. Has no effect unless is
+ /// enabled. If the chosen web storage is unavailable (private mode, disabled, quota exceeded),
+ /// restoration degrades gracefully to in-memory for the session.
+ ///
+ public BrouterScrollPositionStorage ScrollPositionStorage { get; set; } = BrouterScrollPositionStorage.Memory;
+
+ ///
+ /// A CSS selector for the element to move focus to after each successful navigation, mirroring
+ /// Blazor's FocusOnNavigate. Moving focus lets assistive technologies announce the new page
+ /// instead of leaving focus on the activated link, which is a WCAG-relevant concern for an SPA
+ /// router. A fragment target (see ) takes precedence when present.
+ /// If the selector matches an element that isn't natively focusable, a tabindex="-1" is
+ /// added so it can receive programmatic focus without entering the sequential Tab order.
+ /// Defaults to null (no focus change). Common values are "h1" or a main-content
+ /// landmark selector such as "main".
+ ///
+ public string? FocusOnNavigateSelector { get; set; }
+
+ ///
+ /// Whether route Loader results are persisted across the SSR/prerender -> interactive
+ /// transition using , so a
+ /// loader that ran during prerender is not run again (double-fetched) when the component becomes
+ /// interactive. Defaults to false.
+ ///
+ ///
+ /// Enabling this serializes loader results with reflection-based System.Text.Json, which is
+ /// not trimming/AOT-safe for arbitrary types. Only enable it when your loader data types are
+ /// JSON-serializable and preserved under trimming. Restoration degrades gracefully: if a value can't
+ /// be rehydrated the loader simply runs again, so a serialization mismatch never breaks navigation.
+ ///
+ public bool PersistLoaderState { get; set; } = false;
+
+ ///
+ /// Default freshness window for loader results when a doesn't set its own
+ /// . Null (the default) means loaders don't cache at all - every
+ /// navigation re-runs them, exactly the pre-caching behavior. See
+ /// for the stale-while-revalidate semantics.
+ ///
+ public TimeSpan? DefaultLoaderStaleTime { get; set; }
+
+ ///
+ /// How long a cached loader result may live at all. Entries older than this are dropped on
+ /// lookup regardless of staleness handling, bounding how outdated a stale-while-revalidate
+ /// render can ever be. Defaults to 30 minutes (TanStack Router's gcTime default).
+ ///
+ public TimeSpan LoaderCacheGcTime { get; set; } = TimeSpan.FromMinutes(30);
+
+ ///
+ /// Upper bound on cached loader results; the oldest-written entries are evicted first.
+ /// Defaults to 50 (mirrors the scroll-position store's cap).
+ ///
+ public int MaxLoaderCacheEntries { get; set; } = 50;
+
+ ///
+ /// How a stale (but not yet garbage-collected) cached loader result is served.
+ /// (default) renders the cached data immediately
+ /// and refreshes it in the background - classic stale-while-revalidate;
+ /// treats stale as a miss and waits for the loader.
+ ///
+ public BrouterStaleReloadMode StaleReloadMode { get; set; } = BrouterStaleReloadMode.Background;
+
+ ///
+ /// Default preload behavior for every that doesn't set its own
+ /// . Defaults to .
+ ///
+ public BrouterLinkPreload DefaultLinkPreload { get; set; } = BrouterLinkPreload.None;
+
+ ///
+ /// Default retained-instance budget for routes that don't set their
+ /// own . At the default of 1 a keep-alive route keeps a single
+ /// live instance that re-binds when its parameter values change; a value above 1 keeps up to that
+ /// many instances per route, keyed by the route's matched parameter values and evicted
+ /// least-recently-used - so /item/1 and /item/2 each resume their own exact state.
+ /// Values below 1 are treated as 1. See for the full semantics.
+ ///
+ public int DefaultKeepAliveMax { get; set; } = 1;
+
+ ///
+ /// Debounce for preloading: the pointer must rest on the
+ /// link this long before the preload fires, so merely brushing past links doesn't fetch.
+ /// Defaults to 50 ms (TanStack Router's defaultPreloadDelay).
+ ///
+ public TimeSpan PreloadDelay { get; set; } = TimeSpan.FromMilliseconds(50);
+
+ ///
+ /// Freshness window for cache entries produced by link preloading ( /
+ /// ) on routes that don't otherwise cache (no
+ /// ). A preloaded result younger than this is used instead of
+ /// re-running the loader when the user actually navigates. Defaults to 30 seconds (TanStack
+ /// Router's preloadStaleTime default).
+ ///
+ public TimeSpan PreloadStaleTime { get; set; } = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Optional used to
+ /// serialize loader results for . Supply a source-generated
+ /// JsonSerializerContext covering your loader data types to make the prerender state
+ /// bridge fully trimming/AOT-safe; when null (the default) reflection-based
+ /// System.Text.Json is used. Types the resolver can't handle degrade gracefully: their
+ /// results simply aren't persisted, so the loader re-runs on the interactive pass.
+ ///
+ public System.Text.Json.Serialization.Metadata.IJsonTypeInfoResolver? LoaderStateTypeInfoResolver { get; set; }
+
+ ///
+ /// When true, each successful navigation's re-render is wrapped in the browser's View
+ /// Transitions API (document.startViewTransition), giving an animated cross-fade between
+ /// the outgoing and incoming pages by default and enabling per-element morph animations via the
+ /// standard view-transition-name CSS property - no Blazor-specific animation code needed.
+ /// Mirrors Angular's withViewTransitions and React Router's viewTransition.
+ /// Gracefully inert on browsers without the API, during prerender, and in non-browser hosts.
+ /// Defaults to false. See also .
+ ///
+ public bool ViewTransitions { get; set; } = false;
+
+ ///
+ /// When true (the default) and is enabled, Brouter injects a
+ /// small stylesheet of polished, direction-aware default animations so navigations look good out
+ /// of the box: a forward navigation (push) glides the new page in, Back/Forward (pop) mirrors the
+ /// motion so going back feels like going back, a replace does a quick in-place fade, and
+ /// shared-element morphs (view-transition-name) get a springy glide.
+ /// prefers-reduced-motion (which OS accessibility settings propagate to the browser, e.g.
+ /// Windows "Animation effects" off) swaps the slides for gentle opacity-only crossfades and
+ /// disables morph motion, so navigation keeps visual feedback without movement.
+ ///
+ /// The injected rules live in the CSS layer bit-brouter, so any unlayered
+ /// ::view-transition-* rules in application CSS override them automatically - customize
+ /// freely without fighting specificity, or set this to false to opt out entirely (the
+ /// browser's plain cross-fade / your own CSS only). The current navigation's direction is exposed
+ /// as data-brouter-nav="push|replace|pop" on the root element for custom CSS to key off.
+ ///
+ ///
+ public bool ViewTransitionDefaultAnimations { get; set; } = true;
+
+ ///
+ /// Whether the built-in default animations honor the user's prefers-reduced-motion
+ /// preference (the default, true): motion is replaced by gentle opacity-only crossfades
+ /// and shared-element morphs are stilled. Set to false to run the full animations
+ /// regardless of the preference.
+ ///
+ ///
+ /// Think before disabling: reduce is a genuine accessibility signal for motion-sensitive
+ /// users. The legitimate reason to bypass it is that operating systems also report it for
+ /// non-accessibility reasons - e.g. Windows "Animation effects" is commonly switched off on
+ /// VMs, remote-desktop sessions and performance-tuned machines, making every browser there
+ /// report reduce even though no user asked for less motion. Only affects Brouter's
+ /// injected defaults (); your own
+ /// ::view-transition-* CSS is never touched.
+ ///
+ public bool ViewTransitionRespectReducedMotion { get; set; } = true;
+
+ ///
+ /// When true, leaving the SPA entirely - closing the tab, a full page reload, or following
+ /// a link to another document - triggers the browser's generic "leave site?" confirmation dialog
+ /// (a beforeunload handler), armed once the router becomes interactive. Complements
+ /// , which covers in-SPA navigations with full custom logic.
+ /// Browser rules apply: the dialog only appears after the user has interacted with the page, and
+ /// its text cannot be customized. For dynamic control (e.g. only while a form is dirty), leave
+ /// this false and toggle at
+ /// runtime instead. Defaults to false.
+ ///
+ public bool ConfirmExternalNavigation { get; set; } = false;
+
+ ///
+ /// Custom route parameter constraints scoped to this DI container. Register at startup so templates
+ /// can use them, e.g. AddBitBrouterServices(o => o.Constraints.Register("slug", new SlugConstraint())),
+ /// then {post:slug} in a route. Constraints registered here are visible only to the app/service
+ /// provider that owns these options, so separate apps in one process (and parallel test classes) stay
+ /// isolated. Built-in constraints (int, bool, guid, long, float,
+ /// double, decimal, datetime) are always available and need no registration.
+ ///
+ public BrouterConstraintRegistry Constraints { get; } = new();
}
diff --git a/src/Brouter/Bit.Brouter/BrouterOutlet.cs b/src/Brouter/Bit.Brouter/BrouterOutlet.cs
index 52eaa84656..6b0ef383fd 100644
--- a/src/Brouter/Bit.Brouter/BrouterOutlet.cs
+++ b/src/Brouter/Bit.Brouter/BrouterOutlet.cs
@@ -5,87 +5,313 @@ namespace Bit.Brouter;
///
/// Placeholder that renders the matched child route inside its parent route's content.
/// Equivalent to React Router's <Outlet/> and Vue Router's <router-view/>.
+/// The default (unnamed) outlet hosts the child's Content/Component; an outlet with a
+/// hosts the matched child's same-named fragment
+/// (Vue's named views / Angular's secondary outlets, minus URL serialization).
///
public class BrouterOutlet : ComponentBase, IDisposable
{
- [CascadingParameter(Name = "ParentRoute")] internal BrouterRoute? Parent { get; set; }
+ [CascadingParameter(Name = "ParentRoute")] internal Broute? Parent { get; set; }
+ ///
+ /// The outlet's name. Empty (the default) is the primary outlet rendering the matched child's
+ /// Content/Component; a named outlet renders the child's
+ /// <BrouterView Name="..."> fragment of the same name, or nothing when the child
+ /// declares none.
+ ///
+ [Parameter] public string Name { get; set; } = string.Empty;
- private BrouterRoute? _matchedChild;
- private BrouterRouteParameters _parameters = BrouterRouteParameters.Empty;
+ // Per-child render state. One entry for the currently matched child, plus - on the primary
+ // outlet - one retained entry per ever-matched KeepAlive child (their component subtrees stay
+ // mounted inside a hidden wrapper so their state survives sibling navigations).
+ private sealed class ChildEntry
+ {
+ public required Broute Route;
+ // Retention key for per-parameter keep-alive (Broute.KeepAliveMax > 1): the matched
+ // parameter values, so each visited parameter set owns its own entry/subtree. Constant
+ // (empty) for singleton keep-alive and transient routes - one entry per route.
+ public required string Key;
+ public BrouterRouteParameters Parameters = BrouterRouteParameters.Empty;
+
+ // Cached cascade wrappers, mirroring BrouterRouteRenderer: rebuild only when the
+ // underlying reference changes so CascadingValue change-detection stays quiet.
+ public BrouterRouteData? CachedRouteData;
+ public object? CachedLoadedDataRef;
+ public BrouterRouteMeta? CachedRouteMeta;
+ public object? CachedMetaRef;
+
+ // Keep-alive lifecycle context for this kept child (see BrouterKeepAliveContext). A fresh
+ // instance is minted only when the child's active/hidden state flips, so consumers reading
+ // IsActive in OnParametersSet are notified on each transition without spurious churn.
+ private bool _keepAliveActive;
+ private BrouterKeepAliveContext? _keepAliveContext;
+ public BrouterKeepAliveContext GetKeepAliveContext(bool active)
+ {
+ if (_keepAliveContext is null || _keepAliveActive != active)
+ {
+ _keepAliveContext = new BrouterKeepAliveContext(active);
+ _keepAliveActive = active;
+ }
+ return _keepAliveContext;
+ }
+ }
+
+ private ChildEntry? _current;
+ private readonly List _kept = [];
- internal void Render(BrouterRoute route, BrouterRouteParameters parameters)
+ /// Receives the matched child from the parent route (see ).
+ internal void Render(Broute route, BrouterRouteParameters parameters)
{
- _matchedChild = route;
- _parameters = parameters;
+ // Per-parameter keep-alive (KeepAliveMax > 1) keys retained entries by the matched
+ // parameter values (ComputeKeepAliveKey returns the constant empty key in singleton mode,
+ // preserving the one-entry-that-rebinds behavior).
+ var key = Name.Length == 0 && route.KeepAlive ? route.ComputeKeepAliveKey() : string.Empty;
+
+ if (_current is null
+ || ReferenceEquals(_current.Route, route) is false
+ || string.Equals(_current.Key, key, StringComparison.Ordinal) is false)
+ {
+ _current = _kept.Find(k => ReferenceEquals(k.Route, route) && string.Equals(k.Key, key, StringComparison.Ordinal))
+ ?? new ChildEntry { Route = route, Key = key };
+ }
+ _current.Parameters = parameters;
+
+ // Keep-alive retention is a primary-outlet concern: named outlets render lightweight view
+ // fragments whose state lives in the (kept) primary content anyway.
+ if (Name.Length == 0 && route.KeepAlive)
+ {
+ // LRU order: the most recently active entry lives at the tail (Remove is a no-op for a
+ // fresh entry). SetKey keeps each entry's subtree stable across reorders.
+ _kept.Remove(_current);
+ _kept.Add(_current);
+
+ // Evict this route's least-recently-used hidden entries beyond its budget. Entries of
+ // other keep-alive routes sharing this outlet have their own budgets and are untouched.
+ var max = route.EffectiveKeepAliveMax;
+ var count = 0;
+ foreach (var k in _kept)
+ {
+ if (ReferenceEquals(k.Route, route)) count++;
+ }
+ for (int i = 0; i < _kept.Count && count > max;)
+ {
+ if (ReferenceEquals(_kept[i].Route, route) && ReferenceEquals(_kept[i], _current) is false)
+ {
+ _kept.RemoveAt(i);
+ count--;
+ }
+ else
+ {
+ i++;
+ }
+ }
+ }
+
StateHasChanged();
}
+ /// Re-renders the outlet (named-view fragments changed on a host re-render).
+ internal void Refresh() => StateHasChanged();
+
+ /// Drops any retained entry for a disposed route (see ).
+ internal void ForgetChild(Broute route)
+ {
+ _kept.RemoveAll(k => ReferenceEquals(k.Route, route));
+ if (_current is not null && ReferenceEquals(_current.Route, route))
+ {
+ _current = null;
+ }
+ }
+
+ ///
+ /// Releases every retained (hidden) keep-alive child, keeping only the currently active one.
+ /// Backs ; re-renders so the dropped subtrees are disposed.
+ ///
+ internal void ClearKeepAlive()
+ {
+ var active = _current is not null && _current.Route.Matched ? _current : null;
+ var removed = _kept.RemoveAll(k => ReferenceEquals(k, active) is false);
+ if (removed > 0) StateHasChanged();
+ }
+
+ // Wraps a kept child's content in the keep-alive cascade so it receives activate/deactivate
+ // transitions (see BrouterKeepAliveContext). Only kept (primary-outlet KeepAlive) children get
+ // it; transient content renders unwrapped.
+ private static RenderFragment WrapKeepAlive(ChildEntry entry, bool active, RenderFragment inner) => b =>
+ {
+ var context = entry.GetKeepAliveContext(active);
+ b.OpenComponent>(0);
+ b.AddAttribute(1, "Value", context);
+ b.AddAttribute(2, "IsFixed", false);
+ b.AddAttribute(3, "ChildContent", inner);
+ b.CloseComponent();
+ };
protected override void OnInitialized()
{
if (Parent is null)
throw new InvalidOperationException("An Outlet must be placed inside a Brouter route.");
- Parent.Outlet = this;
+ Parent.RegisterOutlet(Name, this);
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
base.BuildRenderTree(builder);
- // Also check Matched: when navigating from a child URL back to the parent (or to any
- // URL where no child of this outlet matches), the previously matched child Route never
- // calls Render() again because its renderer skips RenderRoute while Matched == false.
- // Without this guard the outlet would keep rendering the stale child. Brouter resets
- // Matched on all routes at the start of every navigation and only the winning chain
- // is set back to true, so Matched is the authoritative "is this still selected" flag.
- if (_matchedChild is null || _matchedChild.Matched is false) return;
+ // Matched is the authoritative "still selected" flag: Brouter resets it on every navigation
+ // and only the winning chain gets it back, so a stale _current from a previous navigation
+ // renders nothing (kept entries render hidden).
+ var current = _current is not null && _current.Route.Matched ? _current : null;
+
+ if (Name.Length > 0)
+ {
+ // Named outlet: render the matched child's same-named view fragment, if any.
+ if (current is null) return;
+ var view = current.Route.NamedViews is { } views && views.TryGetValue(Name, out var fragment)
+ ? fragment
+ : null;
+ if (view is null) return;
+
+ RenderChild(builder, current, b => b.AddContent(0, view(current.Parameters)));
+ return;
+ }
+
+ if (current is null && _kept.Count == 0) return;
+
+ // Region 0: retained KeepAlive children. Each stays mounted inside a div that is hidden
+ // unless it is the current match; the stable element (keyed by route) is what preserves
+ // the component subtree - and its state - across visibility flips.
+ builder.OpenRegion(0);
+ // Constant sequence numbers per iteration (the canonical keyed-list pattern): entries move
+ // positions on LRU reorders, and a moved entry must keep the SAME sequence numbers for its
+ // frames or the diff rebuilds its subtree - destroying the very state being kept.
+ foreach (var entry in _kept)
+ {
+ var isActive = ReferenceEquals(entry, current);
+ builder.OpenElement(0, "div");
+ // Keyed by the entry (stable per route + parameter key), not the route alone, so
+ // per-parameter keep-alive entries of the same route each keep their own subtree.
+ builder.SetKey(entry);
+ if (isActive is false) builder.AddAttribute(1, "hidden", true);
+ builder.OpenRegion(2);
+ RenderChild(builder, entry, WrapKeepAlive(entry, isActive, EmitRoutedContent(entry)), refreshData: isActive);
+ builder.CloseRegion();
+ builder.CloseElement();
+ }
+ builder.CloseRegion();
+
+ // Region 1: the current match when it isn't a kept entry - the classic transient path,
+ // rendered without any wrapper element (unchanged markup for non-KeepAlive routes).
+ builder.OpenRegion(1);
+ if (current is not null && _kept.Contains(current) is false)
+ {
+ RenderChild(builder, current, EmitRoutedContent(current));
+ }
+ builder.CloseRegion();
+ }
+
+ // The matched child's error-boundary/content/component trio, identical in behavior to the
+ // pre-named-outlet rendering.
+ private RenderFragment EmitRoutedContent(ChildEntry entry) => b2 =>
+ {
+ var child = entry.Route;
+
+ if (child.CurrentError is not null && child.ErrorContent is not null)
+ {
+ b2.AddContent(0, child.ErrorContent(child.CurrentError));
+ }
+ else if (child.Content is not null)
+ {
+ b2.AddContent(0, child.Content(entry.Parameters));
+ }
+ else if (child.Component is not null)
+ {
+ b2.OpenComponent(0, child.Component);
+ BrouterRouteRenderer.ApplyTypedParameters(b2, child.Component, entry.Parameters, child.Brouter?.CurrentLocation,
+ child.BindComponentParametersByName ? child.TemplateParameterNames : null);
+ b2.CloseComponent();
+ }
+
+ // Deliberately NOT rendering child.ChildContent here: the child's own renderer always
+ // renders it at the declaration site (that's what registers descendant s and
+ // s). Rendering a second copy inside the outlet would mount every descendant
+ // component twice - duplicate route registrations (ambiguity errors) and, for
+ // BrouterView, an infinite register->refresh->re-render loop.
+ };
+
+ ///
+ /// Wraps in the child's cascade stack (Outlet marker, ParentRoute,
+ /// RouteParameters, RouteData, RouteMeta) - the child's own values, not the hosting layout's,
+ /// because the DOM renders here rather than at the child route's declaration site.
+ ///
+ private void RenderChild(RenderTreeBuilder builder, ChildEntry entry, RenderFragment content, bool refreshData = true)
+ {
+ var child = entry.Route;
+
+ // refreshData is false for kept-but-hidden entries: their data/meta stay frozen at the
+ // values they were deactivated with (the route's live LoadedData belongs to the currently
+ // active parameter set). The null checks still run so a first render always has wrappers.
+ var loadedData = child.LoadedData;
+ if (entry.CachedRouteData is null || (refreshData && ReferenceEquals(entry.CachedLoadedDataRef, loadedData) is false))
+ {
+ entry.CachedRouteData = loadedData is null ? BrouterRouteData.Empty : new BrouterRouteData(loadedData);
+ entry.CachedLoadedDataRef = loadedData;
+ }
+ var meta = child.Meta;
+ if (entry.CachedRouteMeta is null || (refreshData && ReferenceEquals(entry.CachedMetaRef, meta) is false))
+ {
+ entry.CachedRouteMeta = meta is null ? BrouterRouteMeta.Empty : new BrouterRouteMeta(meta);
+ entry.CachedMetaRef = meta;
+ }
+ var routeData = entry.CachedRouteData;
+ var routeMeta = entry.CachedRouteMeta;
+ var parameters = entry.Parameters;
builder.OpenComponent>(0);
builder.AddAttribute(1, "Name", "Outlet");
builder.AddAttribute(2, "Value", this);
-
builder.AddAttribute(3, "ChildContent", (RenderFragment)(b =>
{
- // Re-establish ParentRoute for any nested routes declared inside the matched child's content,
- // so they can register themselves and recurse correctly.
- b.OpenComponent>(0);
+ // Re-establish ParentRoute for any nested routes declared inside the matched child's
+ // content, so they can register themselves and recurse correctly.
+ b.OpenComponent>(0);
b.AddAttribute(1, "Name", "ParentRoute");
- b.AddAttribute(2, "Value", _matchedChild);
- b.AddAttribute(3, "ChildContent", (RenderFragment)(b2 =>
+ b.AddAttribute(2, "Value", child);
+ b.AddAttribute(3, "ChildContent", (RenderFragment)(bp =>
{
- if (_matchedChild.Content is not null)
- {
- b2.AddContent(0, _matchedChild.Content(_parameters));
- }
- else if (_matchedChild.Component is not null)
+ bp.OpenComponent>(0);
+ bp.AddAttribute(1, "Name", "RouteParameters");
+ bp.AddAttribute(2, "Value", parameters);
+ bp.AddAttribute(3, "IsFixed", false);
+ bp.AddAttribute(4, "ChildContent", (RenderFragment)(bd =>
{
- b2.OpenComponent(0, _matchedChild.Component);
- BrouterRouteRenderer.ApplyTypedParameters(b2, _matchedChild.Component, _parameters, _matchedChild.Brouter?.CurrentLocation);
- b2.CloseComponent();
- }
-
- // Render any descendant routes declared as ChildContent.
- b2.AddContent(1, _matchedChild.ChildContent);
+ bd.OpenComponent>(0);
+ bd.AddAttribute(1, "Value", routeData);
+ bd.AddAttribute(2, "ChildContent", (RenderFragment)(bm =>
+ {
+ bm.OpenComponent>(0);
+ bm.AddAttribute(1, "Value", routeMeta);
+ bm.AddAttribute(2, "ChildContent", content);
+ bm.CloseComponent();
+ }));
+ bd.CloseComponent();
+ }));
+ bp.CloseComponent();
}));
b.CloseComponent();
}));
-
builder.CloseComponent();
}
-
public void Dispose()
{
if (_disposed) return;
_disposed = true;
- _matchedChild = null;
- // Only detach from the parent if it still points at *this* instance. A newer Outlet may
- // have already taken our place (e.g. after a re-render that recreates the component),
- // and we must not unregister it.
- if (Parent is not null && ReferenceEquals(Parent.Outlet, this)) Parent.Outlet = null;
+ _current = null;
+ _kept.Clear();
+ Parent?.UnregisterOutlet(Name, this);
}
private bool _disposed;
diff --git a/src/Brouter/Bit.Brouter/BrouterQueryBuilder.cs b/src/Brouter/Bit.Brouter/BrouterQueryBuilder.cs
new file mode 100644
index 0000000000..0e3e8d0dfc
--- /dev/null
+++ b/src/Brouter/Bit.Brouter/BrouterQueryBuilder.cs
@@ -0,0 +1,108 @@
+using System.Text;
+
+namespace Bit.Brouter;
+
+///
+/// Mutable view of a URL's query string for functional updates via
+/// : seeded with the current query's pairs, mutated with
+/// ///, then serialized
+/// back. Untouched parameters are preserved - the point of the API (TanStack Router's functional
+/// search updates / retainSearchParams).
+///
+public sealed class BrouterQueryBuilder
+{
+ // Insertion-ordered so the emitted query keeps a stable, predictable parameter order.
+ private readonly List _order = [];
+ private readonly Dictionary> _values = new(StringComparer.OrdinalIgnoreCase);
+
+ internal BrouterQueryBuilder(BrouterLocation location)
+ {
+ foreach (var pair in location.QueryParams)
+ {
+ _order.Add(pair.Key);
+ _values[pair.Key] = [.. pair.Value];
+ }
+ }
+
+ ///
+ /// Sets to a single value, replacing any existing values. A null value
+ /// removes the parameter (mirroring how null route parameters mean "absent"). Non-string values
+ /// are formatted invariantly the same way formats them.
+ ///
+ public BrouterQueryBuilder Set(string key, object? value)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+ if (value is null) return Remove(key);
+
+ if (_values.ContainsKey(key) is false) _order.Add(key);
+ _values[key] = [BrouterService.FormatRouteValue(value)];
+ return this;
+ }
+
+ /// Sets to multiple values (?tag=a&tag=b), replacing existing ones. Null items are skipped; an empty set removes the key.
+ public BrouterQueryBuilder SetAll(string key, IEnumerable
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@* Optional parameter: /profile or /profile/saleh both match. *@
-
+
@* Catch-all parameter: /posts/2024/12/hello-world binds the whole tail. *@
-
+
-
+
Test2 pattern
@@ -87,23 +157,23 @@
postfix
@p["postfix"]
-
+
@* Wildcard route - only wins if nothing more specific matches. *@
-
+
+
+
+ @*============================================================================*@
+ @* Keep-alive: /sticky vs the /fleeting control shows singleton retention plus the
+ activate/deactivate lifecycle (BrouterKeepAliveContext pauses the page's timer while
+ hidden); /notes/{id} shows per-parameter caching (KeepAliveMax) with LRU eviction and
+ IBrouter.ClearKeepAlive(). *@
+
+
+
+
+
+
+
+
+
+
+
+ @*============================================================================*@
+ @* View Transitions showcase: shared-element morphs between the gallery grid and the item
+ detail hero via matching view-transition-name declarations (pure CSS wiring). *@
+
+
+
+
+ @*============================================================================*@
+ @* Named outlets + pathless group: one child route fills two layout regions; the group
+ attaches a shared guard to every dashboard child without adding a URL segment. *@
+
+
+
+
+
Dashboard (named outlets)
+
+ One child route drives two regions. Entries into this section, counted by the
+ pathless group's shared guard: @_dashboardVisits
+
+
+
+
+
+
Main (primary outlet)
+
+
+
+
Sidebar (named outlet)
+
+
+
+
+
+
+
+
+
Stats main content, rendered by the primary outlet.
+
+
+
Stats filters - a <BrouterView Name="sidebar"> rendered by the same-named outlet.
+
+
+
+
+
Activity main content. This route declares no sidebar view, so the named outlet stays empty.
+
+
+
+
+
+
+
+ @* Shown while a navigation awaits slow loaders (visible on the first, uncached visit to /data). *@
+
+
Loading route data…
+
+
+ @* Router-level error boundary: failures on routes without their own ErrorContent land here. *@
+
+
+
Navigation failed
+
@err.Exception.Message
+
+
+
diff --git a/src/Brouter/InteralDemos/Core/Bit.Brouter.Demos.Core.csproj b/src/Brouter/InteralDemos/Core/Bit.Brouter.Demos.Core.csproj
index b165cf3c4b..0a1b0b7350 100644
--- a/src/Brouter/InteralDemos/Core/Bit.Brouter.Demos.Core.csproj
+++ b/src/Brouter/InteralDemos/Core/Bit.Brouter.Demos.Core.csproj
@@ -9,6 +9,10 @@
+
+
diff --git a/src/Brouter/InteralDemos/Core/DemoState.cs b/src/Brouter/InteralDemos/Core/DemoState.cs
new file mode 100644
index 0000000000..61d10fb836
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/DemoState.cs
@@ -0,0 +1,27 @@
+namespace Bit.Brouter.Demos.Core;
+
+///
+/// Scoped state shared between demo pages and the routes declared in AppRouter - the realistic
+/// pattern for a LeaveGuard that needs to know whether a page is dirty, and for showing the last
+/// awaited navigation outcome after the page that triggered it has unmounted.
+///
+public sealed class DemoState
+{
+ /// Set by EditorPage; read by the /editor route's LeaveGuard.
+ public bool IsEditorDirty { get; set; }
+
+ /// The most recent NavigateAsync outcome, recorded by OutcomesPage.
+ public string? LastOutcome { get; set; }
+
+ /// Whether the /unstable route's loader should throw (toggled from UnstablePage's error UI).
+ public bool UnstableShouldFail { get; set; } = true;
+}
+
+/// Payload produced by the /data route's loader (see AppRouter.LoadData).
+public sealed record LoadedInfo(DateTime LoadedAt, bool WasRevalidation, string Page);
+
+///
+/// Payload produced by the /deferred route's loader: the critical part resolves before render,
+/// the slow part is an unawaited task streamed in via <BrouterAwait>.
+///
+public sealed record DeferredReport(string Summary, Task SlowDetails);
diff --git a/src/Brouter/InteralDemos/Core/Extensions/IServiceCollectionExtensions.cs b/src/Brouter/InteralDemos/Core/Extensions/IServiceCollectionExtensions.cs
index 8a1df676e0..6fc91bbe97 100644
--- a/src/Brouter/InteralDemos/Core/Extensions/IServiceCollectionExtensions.cs
+++ b/src/Brouter/InteralDemos/Core/Extensions/IServiceCollectionExtensions.cs
@@ -1,4 +1,5 @@
using Bit.Brouter;
+using Bit.Brouter.Demos.Core;
namespace Microsoft.Extensions.DependencyInjection;
@@ -9,7 +10,27 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service
ArgumentNullException.ThrowIfNull(services);
// Services registered in this class can be injected in client side (Web, Android, iOS, Windows, macOS)
- services.AddBitBrouterServices();
+ services.AddBitBrouterServices(o =>
+ {
+ // Scroll & focus management: new navigations land at the top, Back/Forward restores
+ // where you left each page (persisted per-tab), and focus moves to the page heading
+ // so assistive tech announces it.
+ o.ScrollBehavior = BrouterScrollMode.ToTop;
+ o.RestoreScrollPosition = true;
+ o.ScrollPositionStorage = BrouterScrollPositionStorage.SessionStorage;
+ o.FocusOnNavigateSelector = "h1";
+
+ // Animate page changes with the browser's View Transitions API (inert where unsupported).
+ o.ViewTransitions = true;
+
+ // Demo-only: run the full animations even when the OS reports prefers-reduced-motion.
+ // Windows "Animation effects" is commonly off on VMs/remote desktops for performance,
+ // which would otherwise reduce this showcase to plain crossfades. Real applications
+ // should usually leave this at its default (true) and respect the user's preference.
+ o.ViewTransitionRespectReducedMotion = false;
+ });
+
+ services.AddScoped();
return services;
}
diff --git a/src/Brouter/InteralDemos/Core/GalleryCatalog.cs b/src/Brouter/InteralDemos/Core/GalleryCatalog.cs
new file mode 100644
index 0000000000..05ac9ac564
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/GalleryCatalog.cs
@@ -0,0 +1,29 @@
+namespace Bit.Brouter.Demos.Core;
+
+/// One tile/hero of the View Transitions gallery demo (see GalleryPage / GalleryItemPage).
+public sealed record GalleryItem(int Id, string Emoji, string Name, string Gradient, string Blurb);
+
+///
+/// Static catalog backing the View Transitions gallery. The gradient doubles as the shared visual
+/// that makes the tile-to-hero morph read clearly.
+///
+public static class GalleryCatalog
+{
+ public static readonly GalleryItem[] Items =
+ [
+ new(1, "🌋", "Volcano", "linear-gradient(135deg, #f83600, #f9d423)",
+ "The tile you clicked kept its view-transition-name on this page, so the browser morphed it into this hero - position, size and gradient interpolate in one motion."),
+ new(2, "🌊", "Ocean", "linear-gradient(135deg, #2193b0, #6dd5ed)",
+ "No JavaScript animates this: the CSS view-transition-name on the tile and this hero is the entire wiring; the browser does the rest."),
+ new(3, "🌸", "Blossom", "linear-gradient(135deg, #ee9ca7, #ffdde1)",
+ "Everything without a matching name (the header, this text) simply cross-fades underneath the morph."),
+ new(4, "🌌", "Nebula", "linear-gradient(135deg, #41295a, #7b2ff7)",
+ "Use the previous/next links below to hop between items - each hop morphs this hero into the next one directly."),
+ new(5, "🍃", "Meadow", "linear-gradient(135deg, #11998e, #38ef7d)",
+ "The timing/easing lives in app.css under ::view-transition-group - tune it there, ship no extra code."),
+ new(6, "🏜️", "Dunes", "linear-gradient(135deg, #c79081, #dfa579)",
+ "On browsers without the View Transitions API this page still works identically - navigation just doesn't animate."),
+ ];
+
+ public static GalleryItem? Find(int id) => Array.Find(Items, i => i.Id == id);
+}
diff --git a/src/Brouter/InteralDemos/Core/Pages/DataPage.razor b/src/Brouter/InteralDemos/Core/Pages/DataPage.razor
new file mode 100644
index 0000000000..5c1ba9aae4
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/Pages/DataPage.razor
@@ -0,0 +1,74 @@
+@* Demos: route Loader + cascaded BrouterRouteData/BrouterRouteMeta, StaleTime (SWR cache),
+ RevalidateAsync, ClearLoaderCache, and functional query updates via NavigateWithQuery.
+ The route (declared in AppRouter) has StaleTime=30s and a deliberately slow loader, so:
+ - first visit shows the router-level Navigating pending UI for ~600ms,
+ - re-visiting within 30s is instant (cache hit; LoadedAt doesn't change),
+ - hovering the preloading links on the home page warms this page before you click. *@
+
+@inject IBrouter brouter
+
+
+ Navigate away and come back within 30s: Loaded at stays the same - the loader was
+ skipped (fresh cache hit). After 30s the cached value renders instantly and refreshes in
+ the background (stale-while-revalidate).
+
+
+
+
+
Refresh
+
+
+
+ RevalidateAsync re-runs this chain's loaders in place - the URL doesn't change,
+ guards don't re-run, and this content stays visible until fresh data lands
+ (Was revalidation flips to True).
+
+
+
+
+
Query updates
+
+ page @Info.Page
+
+
+ NavigateWithQuery(q => q.Set("page", n)) updates one parameter, preserves the
+ rest, and replaces the history entry by default. Each page value is its own cache entry.
+
+
+}
+
+@code {
+ [CascadingParameter] public BrouterRouteData? Data { get; set; }
+ [CascadingParameter] public BrouterRouteMeta? Meta { get; set; }
+
+ private LoadedInfo? Info => Data?.GetOrDefault();
+
+ private Task Revalidate() => brouter.RevalidateAsync().AsTask();
+
+ private Task HardRefresh()
+ {
+ brouter.ClearLoaderCache();
+ return brouter.RevalidateAsync().AsTask();
+ }
+
+ private void SetPage(int page) =>
+ brouter.NavigateWithQuery(q => q.Set("page", Math.Max(1, page)));
+}
diff --git a/src/Brouter/InteralDemos/Core/Pages/DeferredPage.razor b/src/Brouter/InteralDemos/Core/Pages/DeferredPage.razor
new file mode 100644
index 0000000000..c5956793ee
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/Pages/DeferredPage.razor
@@ -0,0 +1,42 @@
+@* Demos: deferred (streamed) data with . The route's loader (see AppRouter) awaits
+ only the fast summary; the slow details are returned as an UNAWAITED task, so the page reveals
+ immediately and the details stream in ~1.5s later. *@
+
+
+
Deferred data
+
Critical data blocks navigation; slow data streams in via <BrouterAwait>.
+ This route was found by scanning the assembly (AppAssembly) - it is
+ not declared in AppRouter.razor. Parameters bind by name,
+ just like a plain Blazor @@page.
+
+
+
+
+
id (route)
@Id
+
?q (query)
@(Q ?? "-")
+
+
+@code {
+ [Parameter] public int Id { get; set; }
+
+ [Parameter, SupplyParameterFromQuery] public string? Q { get; set; }
+}
diff --git a/src/Brouter/InteralDemos/Core/Pages/EditorPage.razor b/src/Brouter/InteralDemos/Core/Pages/EditorPage.razor
new file mode 100644
index 0000000000..2bb9134b95
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/Pages/EditorPage.razor
@@ -0,0 +1,62 @@
+@* Demos: per-route LeaveGuard (declared on the /editor route in AppRouter, reading DemoState) and
+ runtime external-navigation confirmation. Type something, then try to navigate away: the leave
+ guard cancels preventively - the URL never changes. Discard to leave. *@
+
+@inject IBrouter brouter
+@inject DemoState State
+
+
+
Leave guard (unsaved changes)
+
A dirty editor blocks in-SPA navigation via LeaveGuard; tab-close/reload via beforeunload.
+ While dirty, every navigation away is cancelled preventively: no address-bar flicker,
+ no broken Back button. A navigation between this route's own parameters wouldn't fire the
+ guard - only actually leaving does.
+
+
+
+
+
External navigation
+
+
+ Toggles SetConfirmExternalNavigationAsync at runtime - the pattern a dirty-form
+ tracker uses. (Browsers only show the dialog after you've interacted with the page.)
+
+ ← All items (morphs back)
+ @if (Prev is not null)
+ {
+ @Prev.Emoji @Prev.Name
+ }
+ @if (Next is not null)
+ {
+ @Next.Emoji @Next.Name
+ }
+
+}
+
+@code {
+ [Parameter, BrouterParameter] public int Id { get; set; }
+
+ private GalleryItem? Item => GalleryCatalog.Find(Id);
+ private GalleryItem? Prev => GalleryCatalog.Find(Id - 1);
+ private GalleryItem? Next => GalleryCatalog.Find(Id + 1);
+}
diff --git a/src/Brouter/InteralDemos/Core/Pages/GalleryPage.razor b/src/Brouter/InteralDemos/Core/Pages/GalleryPage.razor
new file mode 100644
index 0000000000..e7c81777b4
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/Pages/GalleryPage.razor
@@ -0,0 +1,33 @@
+@* View Transitions showcase (o.ViewTransitions = true). Each tile carries a unique
+ view-transition-name; the detail page gives its hero the SAME name, so clicking a tile makes the
+ browser morph it into the hero - a shared-element transition with zero animation code. *@
+
+
+
View Transitions gallery
+
+ Click a card - it morphs into the detail page's hero. That's a shared-element
+ transition: the tile and the hero declare the same view-transition-name, and the
+ browser animates position, size and paint between them. Everything else cross-fades.
+
+ The router side is one option (o.ViewTransitions = true) - it wraps each navigation's
+ re-render in document.startViewTransition. The morph wiring is pure CSS
+ (view-transition-name here and on the detail hero). The page glide you see on every
+ navigation - forward on links, mirrored on Back - is Brouter's built-in default
+ (o.ViewTransitionDefaultAnimations); override it with any unlayered
+ ::view-transition-* rule in your CSS, or turn it off. Browsers without the API
+ navigate identically, just without the animation.
+
diff --git a/src/Brouter/InteralDemos/Core/Pages/HistoryStatePage.razor b/src/Brouter/InteralDemos/Core/Pages/HistoryStatePage.razor
new file mode 100644
index 0000000000..b6095abc64
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/Pages/HistoryStatePage.razor
@@ -0,0 +1,36 @@
+@* Demos: history entry state. Each push attaches a payload to its history entry; Back/Forward
+ restore the entry's own payload via BrouterLocation.HistoryState. *@
+
+@inject IBrouter brouter
+
+
+
History entry state
+
Attach state to a history entry, read it back - it survives Back/Forward.
+ Push a few stamped entries, then walk Back/Forward: each entry restores its own
+ state - the browser stores it on history.state. Serialize JSON for structured payloads.
+
Routes that conditionally redirect via guards, cancel leaves, or block outright.
/g1 - allowed when second % 2 == 0
/g2 - allowed when minute % 2 == 0
+
Leave guard - unsaved-changes editor
+
+
+
+
+
+
+ Data layer
+ loader
+
+
+ Loaders, the StaleTime cache, revalidation, deferred data and error boundaries.
+ The links below preload on hover (Preload="Intent") - rest the
+ pointer on one for a moment and the slow loader is already done when you click.
+
+
+
Loader + cache + revalidate + query updates
+
Deferred (streamed) data via BrouterAwait
+
Error boundary with retry (loader throws)
+
+
+
+
+
+
+ Navigation control
+ nav
+
+
Awaited outcomes, history entry state, and the .NET 10 NotFound interop.
+
+
Awaited navigation (NavigateAsync) + NotFound()
+
History entry state across Back/Forward
+
+
+
+
+
+
+ Layout & state
+ layout
+
+
Named outlets, pathless groups, keep-alive routes, and animated page changes via the View Transitions API.
+
+
View Transitions gallery - shared-element morphs between routes
diff --git a/src/Brouter/InteralDemos/Core/Pages/NotebookPage.razor b/src/Brouter/InteralDemos/Core/Pages/NotebookPage.razor
new file mode 100644
index 0000000000..e8b2991219
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/Pages/NotebookPage.razor
@@ -0,0 +1,69 @@
+@* Per-parameter keep-alive demo. The /notes/{id:int} route is declared with
+ KeepAlive KeepAliveMax="2": each visited id keeps its OWN live instance (draft, clicks and all),
+ up to the budget - beyond it the least-recently-used hidden note is evicted (disposed).
+ Also demos IBrouter.ClearKeepAlive(), which drops every retained page on demand. *@
+
+@inject IBrouter brouter
+
+
+
Note @Id per-parameter keep-alive
+
+ /notes/{id:int} with KeepAlive KeepAliveMax="2": every note id you visit
+ keeps its own live instance, LRU-evicted over the budget of 2.
+
+
+
+
+
This note's state (instance #@_instanceStamp)
+
Clicks this note's instance has seen: @_clicks
+
+
+
+
+
+ Type a draft, switch to another note, come back: the draft resumes exactly where you left it -
+ unlike the singleton /sticky keep-alive, notes 1 and 2 do not share
+ an instance. The "instance #" stamp reveals recreation: it changes only when this note's
+ instance was evicted and rebuilt.
+
+
+
+
+
Switch notes (budget: 2)
+
+
Note 1
+
Note 2
+
Note 3
+
+
+ Edit notes 1 and 2, then visit note 3: the budget is 2, so the least-recently-used note is
+ evicted - return to it and its draft is gone (fresh instance #), while the other survivor
+ still resumes. The cache key is the route's template parameter values; query-string
+ variations share one instance.
+
+
+
+
+
Evict on demand
+
+
+ Disposes every retained (hidden) keep-alive page across the app - the other notes here
+ and the /sticky page - keeping only this visible one. Use it on sign-out
+ or to reclaim memory. Routes that don't declare their own budget fall back to
+ BrouterOptions.DefaultKeepAliveMax (default 1: the singleton, re-binding behavior).
+
+
+
+@code {
+ [Parameter, BrouterParameter] public int Id { get; set; }
+
+ private int _clicks;
+ private string _draft = string.Empty;
+
+ // Monotonic stamp handed out per created instance, so eviction (recreate => new stamp) is
+ // visible in the UI without attaching a debugger.
+ private static int _nextStamp;
+ private readonly int _instanceStamp = System.Threading.Interlocked.Increment(ref _nextStamp);
+}
diff --git a/src/Brouter/InteralDemos/Core/Pages/OutcomesPage.razor b/src/Brouter/InteralDemos/Core/Pages/OutcomesPage.razor
new file mode 100644
index 0000000000..4b2b92321d
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/Pages/OutcomesPage.razor
@@ -0,0 +1,50 @@
+@* Demos: awaitable navigation (NavigateAsync) and the .NET 10 NavigationManager.NotFound() interop.
+ Outcomes are recorded in the scoped DemoState so they're still visible when a navigation left
+ this page and you came Back. *@
+
+@inject IBrouter brouter
+@inject DemoState State
+@inject NavigationManager navManager
+
+
+
Awaited navigation outcomes
+
NavigateAsync tells you how a navigation actually ended.
+
+
+
+
Last outcome
+
@(State.LastOutcome ?? "none yet - try a button below")
+
+
+
+
Try one
+
+
+
+
+
+
+
+
+
+
.NET 10 NotFound() interop
+
+
+ The framework's not-found signal (e.g. "this entity doesn't exist") flows through Brouter:
+ the URL stays put and the router's not-found handling takes over.
+
+
+
+@code {
+ private async Task Go(string url)
+ {
+ var outcome = await brouter.NavigateAsync(url);
+ State.LastOutcome = outcome.Status switch
+ {
+ BrouterNavigationStatus.Redirected => $"{outcome.Status} → {outcome.RedirectedTo}",
+ BrouterNavigationStatus.Failed => $"{outcome.Status}: {outcome.Exception?.Message}",
+ _ => outcome.Status.ToString(),
+ } + $" (target: {url})";
+ StateHasChanged();
+ }
+}
diff --git a/src/Brouter/InteralDemos/Core/Pages/StickyNotePage.razor b/src/Brouter/InteralDemos/Core/Pages/StickyNotePage.razor
new file mode 100644
index 0000000000..08951eec76
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/Pages/StickyNotePage.razor
@@ -0,0 +1,111 @@
+@* A deliberately stateful page used by the keep-alive demo: the /sticky route mounts it with
+ KeepAlive (state survives navigating away), /fleeting without (state resets). The /sticky
+ variant also consumes the cascaded BrouterKeepAliveContext to pause its background timer
+ while hidden and resume it on return - the activate/deactivate lifecycle. *@
+
+@implements IDisposable
+
+
+
@Title
+
@Description
+
+
+
+
Component state
+
Clicks this instance has seen: @_clicks
+
+
+
+
+
+ Click a few times, type a note, navigate to Home and come back.
+ @(KeepAliveDemo
+ ? "This route is KeepAlive: the component stayed mounted (hidden), so everything survives."
+ : "This route is NOT KeepAlive: the component was disposed and recreated - everything resets.")
+
+
+
+@if (KeepAliveDemo)
+{
+
+
Activate / deactivate lifecycle
+
+ Seconds this page has been visible: @_visibleSeconds
+ Deactivated (hidden) @_deactivations times, reactivated @_activations times.
+
+
+ A kept page stays fully alive while hidden - its timers keep firing unless it pauses them.
+ This page consumes the cascaded BrouterKeepAliveContext and stops its ticking
+ timer when IsActive flips to false, resuming on return. Navigate
+ away for a while and come back: the seconds counter did NOT advance while you were gone,
+ but the deactivation/reactivation counters did - proving the same instance was signalled
+ on each transition instead of being recreated.
+
+
+}
+
+@code {
+ [Parameter] public bool KeepAliveDemo { get; set; }
+
+ // Cascaded by the KeepAlive rendering: IsActive is false while this page is kept but hidden.
+ // A fresh context instance is cascaded on every activate/deactivate flip, so OnParametersSet
+ // runs on each transition. Null on non-keep-alive routes (the /fleeting control).
+ [CascadingParameter] public BrouterKeepAliveContext? KeepAlive { get; set; }
+
+ private int _clicks;
+ private string _note = string.Empty;
+
+ private int _visibleSeconds;
+ private int _activations;
+ private int _deactivations;
+ private bool _wasActive = true;
+ private bool _disposed;
+ private System.Threading.Timer? _timer;
+
+ private bool IsActive => KeepAlive?.IsActive ?? true;
+
+ private string Title => KeepAliveDemo ? "Keep-alive route" : "Transient route (control)";
+ private string Description => KeepAliveDemo
+ ? "Declared with KeepAlive: navigating away hides it instead of disposing it."
+ : "The default behavior: unmatched content unmounts.";
+
+ protected override void OnInitialized()
+ {
+ if (KeepAliveDemo)
+ {
+ _timer = new System.Threading.Timer(_ =>
+ {
+ // Timer.Dispose does not wait for in-flight callbacks, so guard against
+ // touching the component after disposal has started.
+ if (_disposed) return;
+ _visibleSeconds++;
+ _ = InvokeAsync(StateHasChanged);
+ }, null, dueTime: 1000, period: 1000);
+ }
+ }
+
+ protected override void OnParametersSet()
+ {
+ var active = IsActive;
+ if (_wasActive && active is false)
+ {
+ // Deactivated: pause background work while the page is kept but hidden. Without this,
+ // the timer would keep firing (and re-rendering the hidden subtree) the whole time.
+ _deactivations++;
+ _timer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
+ }
+ else if (_wasActive is false && active)
+ {
+ // Reactivated: resume (a real page would also refresh stale data here).
+ _activations++;
+ _timer?.Change(1000, 1000);
+ }
+ _wasActive = active;
+ }
+
+ public void Dispose()
+ {
+ _disposed = true;
+ _timer?.Dispose();
+ }
+}
diff --git a/src/Brouter/InteralDemos/Core/Pages/UnstablePage.razor b/src/Brouter/InteralDemos/Core/Pages/UnstablePage.razor
new file mode 100644
index 0000000000..69b795ff7d
--- /dev/null
+++ b/src/Brouter/InteralDemos/Core/Pages/UnstablePage.razor
@@ -0,0 +1,32 @@
+@* Demos: the success side of the error-boundary route. Its loader (see AppRouter) throws while
+ DemoState.UnstableShouldFail is set; the route's ErrorContent renders the failure with a
+ fix-and-retry button. This page re-arms the failure so the boundary can be seen again. *@
+
+@inject IBrouter brouter
+@inject DemoState State
+
+
+
Unstable route - loaded fine
+
You're seeing the route's Content; last load succeeded.
+
+
+
+
Data
+
@(Data?.GetOrDefault())
+
+
+ The loader will throw again; the nearest ErrorContent - declared on this route -
+ replaces this content, with a retry button that heals it. The global OnError hook
+ fires either way.
+
diff --git a/src/Brouter/InteralDemos/Core/wwwroot/app.css b/src/Brouter/InteralDemos/Core/wwwroot/app.css
index 1b768a3f4e..e241df7509 100644
--- a/src/Brouter/InteralDemos/Core/wwwroot/app.css
+++ b/src/Brouter/InteralDemos/Core/wwwroot/app.css
@@ -417,3 +417,66 @@ button.btn-outline:hover {
color: var(--bb-text-muted);
margin: .5rem 0 1.25rem;
}
+
+/* ---------- View Transitions (o.ViewTransitions = true) ----------
+
+ No custom ::view-transition rules here on purpose: the animations you see (direction-aware
+ glide on push, mirrored on Back/Forward, quick fade on replace, springy shared-element morphs)
+ are Bit.Brouter's BUILT-IN defaults (o.ViewTransitionDefaultAnimations, on by default).
+ They live in the CSS layer "bit-brouter", so any unlayered ::view-transition-* rule added in
+ this file would override them automatically - e.g.:
+
+ ::view-transition-new(root) { animation: 400ms ease both my-fancy-entrance; }
+*/
+
+/* ---------- View Transitions gallery (GalleryPage / GalleryItemPage) ---------- */
+
+.bb-gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: 1rem;
+}
+
+.bb-gallery-tile {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: .35rem;
+ aspect-ratio: 4 / 3;
+ border-radius: var(--bb-radius);
+ color: #fff;
+ text-decoration: none;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, .35);
+ transition: transform .15s ease, box-shadow .15s ease;
+}
+
+.bb-gallery-tile:hover {
+ transform: translateY(-3px) scale(1.02);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, .25);
+}
+
+.bb-gallery-emoji { font-size: 2.2rem; }
+.bb-gallery-name { font-weight: 600; }
+
+.bb-gallery-hero {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: .5rem;
+ min-height: 260px;
+ border-radius: var(--bb-radius);
+ color: #fff;
+ text-shadow: 0 2px 4px rgba(0, 0, 0, .35);
+}
+
+.bb-gallery-hero-emoji { font-size: 4.5rem; }
+.bb-gallery-hero-name { margin: 0; font-size: 2.2rem; }
+
+.bb-gallery-nav {
+ display: flex;
+ gap: .5rem;
+ margin-top: 1rem;
+ flex-wrap: wrap;
+}
diff --git a/src/Brouter/README.md b/src/Brouter/README.md
index 4e430b30bd..7ad066d6af 100644
--- a/src/Brouter/README.md
+++ b/src/Brouter/README.md
@@ -18,34 +18,41 @@ using Bit.Brouter;
builder.Services.AddBitBrouterServices(o =>
{
- o.CaseSensitive = false; // default
- o.IgnoreTrailingSlash = true; // default
+ o.CaseSensitive = false; // default
+ o.IgnoreTrailingSlash = true; // default
o.ScrollBehavior = BrouterScrollMode.ToTop;
+ o.ScrollToFragment = true; // default: /docs#install scrolls #install into view
+ o.FocusOnNavigateSelector = "h1"; // move focus after navigation (accessibility)
});
```
+A runnable tour of every feature below lives in [`InteralDemos`](InteralDemos/) - run the
+[Server](InteralDemos/Server/) project (`dotnet run`) and click through the home page cards; the
+same shared demo pages also run under [WASM](InteralDemos/Wasm/) and [Auto](InteralDemos/Auto/)
+render modes.
+
## Quick start
```razor
-
+
-
+
-
+
-
+
-
+
-
+
-
+
404
Sorry, there's nothing at this address.
-
+
```
@@ -57,34 +64,60 @@ builder.Services.AddBitBrouterServices(o =>
- Wildcards: `*` (single segment), `**` (catch-all)
- **Optional parameters**: `{id?}` - must be trailing
- **Catch-all parameter binding**: `{**path}` exposes the remainder
-- Custom constraints via `BrouterConstraints.Register("slug", new MyConstraint())`
+- Custom constraints, scoped per DI container via `o.Constraints.Register("slug", new MyConstraint())`
- Specificity-based matching (literals beat constrained beat unconstrained beat wildcards)
-- Nested routes via `BrouterRoute` children or `BrouterOutlet`
+- **Ambiguous templates are rejected**: registering two routes that match exactly the same URLs (e.g. a duplicated `@page`, or `/users/{id}` next to `/users/{userId}`) throws instead of silently picking one, mirroring the built-in router's `AmbiguousMatchException`. A hand-declared route may still shadow a discovered `@page` with the same template (see [`@page` discovery](#attribute-route--page-discovery))
+- Nested routes via `Broute` children or `BrouterOutlet`
- Async `Guard` with cancel/redirect via `BrouterNavigationContext`
-- **Async data `Loader`** exposed via cascading `RouteData`
+- **Per-route `LeaveGuard`** (Angular `CanDeactivate` / Vue `beforeRouteLeave` style): runs preventively, leaf → root, only for routes the navigation actually deactivates - real per-route "unsaved changes" prompts
+- **External-navigation confirmation**: `o.ConfirmExternalNavigation` (always-on) or `brouter.SetConfirmExternalNavigationAsync(...)` (runtime toggle) arms the browser's `beforeunload` dialog for tab-close/reload/external links
+- **Per-route error boundaries**: `ErrorContent` on a `Broute` (nearest boundary wins, bubbling leaf → root) or on the `Brouter` (root fallback), with typed `BrouterErrorContext` carrying the exception and a `RetryAsync()`
+- **Awaitable navigation**: `NavigateAsync` resolves with how the navigation actually ended - `Succeeded` / `Cancelled` / `Redirected` / `NotFound` / `Failed` / `Superseded` (Vue Router navigation-failures style)
+- **History entry state**: attach a state string to a navigation (`Navigate(url, historyState: ...)`, ``), read it back on `BrouterLocation.HistoryState` - survives Back/Forward
+- **View Transitions API**: `o.ViewTransitions = true` wraps each navigation's re-render in `document.startViewTransition` with **beautiful direction-aware default animations** out of the box (push glides forward, Back mirrors it, replace fades; overridable via plain CSS thanks to `@layer`, opt-out via `o.ViewTransitionDefaultAnimations`); `view-transition-name` morphs just work; inert on unsupported browsers
+- **.NET 10 `NotFound()` interop**: `NavigationManager.NotFound()` routes through Brouter's not-found handling, and an unmatched URL during static SSR sets a real HTTP 404
+- **Revalidation**: `brouter.RevalidateAsync()` re-runs the matched chain's loaders after a mutation - no guards, no URL change, current content stays while fresh data loads
+- **Loader caching (stale-while-revalidate)**: per-route `StaleTime` (or a global default) caches loader results per URL - fresh hits skip the loader (instant Back/Forward), stale hits render immediately and refresh in the background (TanStack Router style); `GcTime`, entry cap, `Blocking` mode and `ClearLoaderCache()` included
+- **Link preloading**: `` (hover/touch/focus with debounce), `Viewport` (IntersectionObserver) or `Render` warm the loader cache before the click; programmatic `brouter.PreloadAsync(url)`; guards never run on preloads (`ctx.IsPreload`)
+- **Deferred (streamed) data**: return unawaited `Task`s inside the loader result and render them with `` (`Pending`/`Resolved`/`Error`) - critical data blocks navigation, slow data streams in (React Router `` style)
+- **AOT-safe prerender persistence**: plug a source-generated `JsonSerializerContext` into `o.LoaderStateTypeInfoResolver` to make `PersistLoaderState` trimming/AOT-safe
+- **Pathless group routes**: `` attaches a shared guard/loader/layout/error boundary to its children without adding URL segments (SvelteKit `(group)` / TanStack pathless-layout style)
+- **Lazy route loading**: `Brouter OnNavigateAsync` loads route assemblies on demand (e.g. `LazyAssemblyLoader` on WASM) and the *same* navigation matches the freshly-loaded page
+- **Functional query updates**: `brouter.NavigateWithQuery(q => q.Set("page", 2))` updates one parameter and preserves the rest (typed values, multi-value support, replace-by-default)
+- **Source-generated typed routes** (`Bit.Brouter.Generators`): compile-time-safe URL builders generated from your `@page` directives and `` declarations - `BrouterRoutes.Counter(1234)` instead of `"/counter/1234"`, with constraint-typed parameters and a `Names` class for named routes
+- **Named outlets**: `` + `` let one route drive multiple regions of its parent layout (Vue named views / Angular secondary outlets style)
+- **Keep-alive routes**: `` keeps the rendered component mounted (hidden) when navigated away, so returning restores its exact state instead of recreating it (Vue `KeepAlive` / Angular `RouteReuseStrategy` style); `KeepAliveMax="N"` upgrades a parameterized route to per-parameter caching (`/item/1` and `/item/2` each resume their own state, LRU-evicted over the budget); a cascaded `BrouterKeepAliveContext` signals activate/deactivate so pages can pause background work while hidden, and `brouter.ClearKeepAlive()` evicts retained pages on demand
+- **Async data `Loader`** exposed via the typed cascading `BrouterRouteData` wrapper (`Get` / `TryGet` / `GetOrDefault`) - sequential root → leaf by default, with opt-in **`ParallelLoaders`** for independent loaders
- Redirects with `RedirectTo`
- Component or `Content` (typed render fragment) rendering
+- **Pending navigation UI**: a `Navigating` fragment shown while a navigation awaits slow loaders - revealed lazily so loader-less (or cache-hit) navigations never flash it (mirrors the built-in `Router.Navigating`)
+- Router-level hooks: `OnMatch` (a route matched) and `OnNotFound` (nothing matched), alongside the global `IBrouter` events
- `NotFound` URL or inline `NotFoundContent`
- **Type-safe `BrouterRouteParameters`** with `TryGet` / `Get` / `GetOrDefault`
- **Auto-binding** to component properties via `[Parameter, BrouterParameter]`
- **``** component with active-class and `aria-current` (NavLink-style)
- **Programmatic navigation** via `IBrouter`: `Navigate`, `Back`, `NavigateToName`, `ResolveUrl`
+- **Relative navigation**: `./edit` and `../sibling` resolve against the current location (segment math, React Router style) in `Navigate`, guard redirects and ``
- **Global hooks**: `OnNavigating`, `OnNavigated`, `OnError` (Vue Router style)
-- Cancel-then-restore URL semantics (no broken back button after a guard cancels)
+- **Navigation type** on `BrouterNavigationContext.NavigationType`: distinguishes `Push` / `Replace` / `Pop` (Back/Forward) for scroll-restoration and analytics logic
+- **Preventive guards** (via `RegisterLocationChangingHandler`): a cancel/redirect stops the URL from ever changing - no address-bar flicker, no corrupted history/back button, and real "unsaved changes" prompts are possible
- In-flight loader cancellation when navigation is superseded
+- **Attribute-route / `@page` discovery**: scan `AppAssembly` / `AdditionalAssemblies` for `[Route]`-annotated components so routes live colocated with their pages (Razor class libraries and lazy-loaded assemblies included)
+- **Prerender state bridging**: loader results captured during prerender are restored on the interactive pass via `PersistentComponentState`, so loaders don't double-fetch (opt-in)
- Query string and hash exposed via `BrouterLocation`
- Configurable case sensitivity and trailing-slash handling
-- Optional scroll-to-top on navigation
+- **Scroll management**: optional scroll-to-top, fragment scrolling (`/docs#install` lands on `#install`), and scroll-position restoration on Back/Forward
+- **Focus management** for accessibility: move focus to a selector after navigation so screen readers announce the new page (mirrors Blazor's `FocusOnNavigate`)
- Multi-target: net8.0, net9.0, net10.0
## Type-safe parameters
```razor
-
+
User: @p.Get("id")
-
+
```
```razor
@@ -101,7 +134,7 @@ builder.Services.AddBitBrouterServices(o =>
## Auto-bound parameters
```razor
-
+
```
```razor
@@ -114,9 +147,9 @@ builder.Services.AddBitBrouterServices(o =>
## Async guards
```razor
-
+
-
+
@code {
[Inject] AuthService Auth { get; set; } = default!;
@@ -129,14 +162,77 @@ builder.Services.AddBitBrouterServices(o =>
}
```
+Guards (and `OnNavigating`) run inside a `RegisterLocationChangingHandler`, so `ctx.Cancel()` /
+`ctx.Redirect(...)` are **preventive**: the target URL is never committed to history when the
+navigation is blocked. There is no address-bar flicker and no torn back/forward stack, and you can
+implement a genuine "you have unsaved changes" prompt by cancelling from a guard or `OnNavigating`.
+
+A redirect to the URL the navigation is already heading to is treated as "continue", so guards like
+"always send anonymous users to `/login`" can't create redirect loops.
+
+## Leave guards (unsaved changes)
+
+`LeaveGuard` is the per-route counterpart for *leaving*: it runs - preventively, before
+`OnNavigating` and any enter guards - when a navigation would deactivate the route (it is part of
+the currently rendered chain but not the new one), leaf → root. A navigation that keeps the route
+matched (a parameter change, or moving between its children) does not fire it.
+
+```razor
+
+
+
+
+@code {
+ private ValueTask ConfirmLeave(BrouterNavigationContext ctx)
+ {
+ if (_isDirty) ctx.Cancel(); // URL never changes; no flicker, no broken Back
+ return ValueTask.CompletedTask;
+ }
+}
+```
+
+Leaving the SPA entirely (tab close, reload, external link) can't run C# - for that, arm the
+browser's generic confirmation dialog: `o.ConfirmExternalNavigation = true` at startup, or toggle it
+at runtime with `brouter.SetConfirmExternalNavigationAsync(isDirty)` from a dirty-form tracker.
+(Browser rules: the dialog needs prior user interaction and its text is not customizable.)
+
+## Error boundaries
+
+When a commit-phase failure happens (typically a `Loader` throwing), the **nearest `ErrorContent`**
+- walking from the failed route up through its ancestors, then to the `Brouter` itself - renders in
+place of the routed content. Layouts above the boundary keep rendering; the global `OnError` hook
+still fires either way.
+
+```razor
+
+
+
+
+
+
Couldn't load this user: @err.Exception.Message
+
+
+
+
+
+
Something went wrong
+
+
+
+```
+
+`RetryAsync()` re-runs the full navigation (guards included) for the current URL; success replaces
+the error UI with the routed content. With no boundary declared anywhere, behavior is unchanged:
+the previous page stays visible and `OnError` observes the failure.
+
## Data loader
```razor
-
+
- @* reads cascading RouteData *@
+ @* reads the cascading BrouterRouteData *@
-
+
@code {
[Inject] HttpClient Http { get; set; } = default!;
@@ -148,6 +244,131 @@ builder.Services.AddBitBrouterServices(o =>
}
```
+The loader result is cascaded as a typed `BrouterRouteData` wrapper (route `Meta` likewise as
+`BrouterRouteMeta`), so consumers get compile-time-safe access instead of casting an `object?`:
+
+```razor
+@* UserDetails.razor *@
+
@(Data?.Get().Name)
+
+@code {
+ // The cascade is unnamed and matched by the unique wrapper type - no Name string involved.
+ [CascadingParameter] public BrouterRouteData? Data { get; set; }
+}
+```
+
+`Get()` throws a descriptive exception when the value is absent or of another type;
+`TryGet(out var value)` and `GetOrDefault()` are the non-throwing variants, and the raw
+payload stays available via `Data.Value`.
+
+### Loader ordering in nested routes
+
+When a matched route has ancestors with their own loaders, the loaders run **sequentially,
+root → leaf** by default: a parent's loader completes before its child's starts, mirroring guard
+order. That lets a child loader depend on work its parent's loader already did (e.g. state stashed
+in a scoped service), but it means the total wait is the *sum* of the chain's loader times.
+
+If the chain's loaders are independent (the common case), opt into running them concurrently —
+like React Router — with `ParallelLoaders`:
+
+```razor
+
+ ...
+
+```
+
+Results are still committed and errors still surfaced in root → leaf order, so render and failure
+behavior are unchanged; only the awaiting overlaps, making the wait as long as the slowest loader
+instead of all of them combined.
+
+### Pending navigation UI
+
+```razor
+
+
+ ...
+
+
+
Loading…
+
+
+```
+
+While a navigation is awaiting its route loaders, the `Navigating` fragment renders in place of the
+routed content - the counterpart of the built-in `Router.Navigating`. It is revealed *lazily*, only
+once a loader is actually about to run: navigations with no loaders, cache hits, and
+prerender-restored loads never flash it. Left unset, the previous page simply stays visible until
+the new route is ready.
+
+### Revalidation (refresh after a mutation)
+
+```csharp
+await Http.PostAsJsonAsync("/api/todos", newTodo);
+await brouter.RevalidateAsync(); // re-runs the matched chain's loaders, re-renders fresh data
+```
+
+Not a navigation: the URL stays, guards and `OnNavigating`/`OnNavigated` don't run, and the current
+content remains visible while loaders work. Loaders can branch on `ctx.IsRevalidation`. For data on
+*other* pages, `brouter.ClearLoaderCache()` drops every cached loader result instead.
+
+### Loader caching (stale-while-revalidate)
+
+```razor
+...
+```
+
+With a `StaleTime` (per-route, or `o.DefaultLoaderStaleTime` globally), loader results cache per
+URL (path + query):
+
+- **fresh** hit (younger than `StaleTime`) → the loader is skipped entirely - Back/Forward becomes instant;
+- **stale** hit → by default (`o.StaleReloadMode = Background`) the cached data renders immediately
+ and a background revalidation refreshes it (classic SWR); `Blocking` treats stale as a miss;
+- entries die after `o.LoaderCacheGcTime` (30 min default) and the store is capped at
+ `o.MaxLoaderCacheEntries` (50), oldest evicted first.
+
+No `StaleTime` anywhere → no *loader* caching, exactly the previous behavior. (Preloading is the
+one exception: a preloaded result stays reusable for `o.PreloadStaleTime` even on routes without a
+`StaleTime` - see below.)
+
+### Preloading
+
+```razor
+Saleh
+```
+
+`Intent` runs the destination's loaders into the cache on hover/touch/focus (debounced by
+`o.PreloadDelay`, 50 ms); `Viewport` fires once when the link scrolls into view; `Render` fires
+immediately; `o.DefaultLinkPreload` sets an app-wide default. Programmatic:
+`await brouter.PreloadAsync("/users/42")`. Preloads run **loaders only** - no guards, no rendering -
+and a preloaded result younger than `o.PreloadStaleTime` (30 s) is used by the real navigation even
+on routes with no `StaleTime`. Keep preloaded loaders side-effect-free (`ctx.IsPreload` is set).
+
+### Deferred (streamed) data
+
+Let the critical part block navigation and stream the slow part in afterwards:
+
+```csharp
+private async ValueTask LoadPost(BrouterNavigationContext ctx)
+{
+ var post = await Http.GetFromJsonAsync($"/api/posts/{ctx.Parameters["id"]}", ctx.CancellationToken);
+ var comments = Http.GetFromJsonAsync($"/api/posts/{ctx.Parameters["id"]}/comments"); // NOT awaited
+ return new PostData(post!, comments!);
+}
+```
+
+```razor
+
@(Data!.Get().Post.Title)
+
+
+
Loading comments…
+ @foreach (var c in comments) {
@c.Text
}
+
Comments unavailable: @ex.Message
+
+```
+
+Loader results containing live tasks are skipped by `PersistLoaderState` (tasks aren't
+serializable), so such loaders simply re-run on the interactive pass.
+
## Programmatic navigation
```razor
@@ -170,6 +391,84 @@ builder.Services.AddBitBrouterServices(o =>
}
```
+### Awaitable navigation
+
+`NavigateAsync` resolves with how the navigation actually concluded, mirroring Vue Router's
+navigation failures - no more assuming a `Navigate` call landed:
+
+```csharp
+var outcome = await brouter.NavigateAsync("/admin");
+switch (outcome.Status)
+{
+ case BrouterNavigationStatus.Succeeded: /* committed + rendered */ break;
+ case BrouterNavigationStatus.Cancelled: /* a guard said no */ break;
+ case BrouterNavigationStatus.Redirected: /* see outcome.RedirectedTo */ break;
+ case BrouterNavigationStatus.NotFound: /* no route matched */ break;
+ case BrouterNavigationStatus.Failed: /* see outcome.Exception */ break;
+ case BrouterNavigationStatus.Superseded: /* a newer navigation overtook it */ break;
+}
+```
+
+### History entry state
+
+Attach application state to the destination's history entry and read it back after the navigation -
+including when the user returns to the entry via Back/Forward (`history.state` semantics):
+
+```csharp
+brouter.Navigate("/results", historyState: "search=blazor;page=3");
+// later, e.g. in a loader or OnNavigated:
+var state = brouter.Location.HistoryState; // also on ctx.To.HistoryState
+```
+
+`` does the same for link clicks (the link
+intercepts unmodified left-clicks the way `Replace` links do, since an href-driven navigation
+can't carry state). Serialize structured payloads (e.g. JSON) yourself.
+
+### Relative navigation
+
+Paths starting with `./` or `../` resolve against the **current location** using segment math
+(React Router style, not URL directory semantics): from `/users/42`, `Navigate("./edit")` goes to
+`/users/42/edit` and `Navigate("../7")` to `/users/7`. Extra `..` clamp at the root, and any query
+or hash on the relative URL is preserved.
+
+The same resolution applies in guard redirects — `ctx.Redirect("../login")` resolves against the
+path being navigated **to**, so a guard on `/admin/secret` lands on `/admin/login` — and in
+``, whose rendered `href` is the resolved absolute path and
+re-resolves after every (matched) navigation.
+
+Bare paths without a leading `.` (e.g. `Navigate("sibling")`) are untouched and keep their usual
+base-relative meaning through `NavigationManager`.
+
+## Navigation type (push / replace / pop)
+
+`BrouterNavigationContext.NavigationType` tells guards, loaders and hooks how the current navigation
+was initiated, so logic that treats a Back/Forward differently from a fresh navigation (scroll
+restoration, analytics, "leave animation" direction) can branch on it. It is populated before guards
+run and is available for the whole navigation.
+
+```csharp
+private ValueTask LoadFeed(BrouterNavigationContext ctx)
+{
+ if (ctx.NavigationType == BrouterNavigationType.Pop)
+ return ValueTask.FromResult(_cachedFeed); // Back/Forward: reuse, don't refetch
+ ...
+}
+```
+
+- `Push` - a new history entry: an intercepted link click, `brouter.Navigate(...)` /
+ `brouter.NavigateToName(...)` without `replace`, an internal redirect, and the initial page load.
+- `Replace` - the current entry was replaced: `brouter.Navigate(url, replace: true)`, a
+ `` click, or the address-bar restore after a cancelled navigation.
+- `Pop` - a history traversal: browser Back/Forward, or `brouter.Back()` / `brouter.Forward()`.
+
+Detection relies on navigation going through Brouter's own primitives (links and `IBrouter`) -
+`brouter.Back()` / `Forward()` stamp the traversal explicitly, so they always report `Pop`. Two
+framework-level caveats: a raw `NavigationManager.NavigateTo` that bypasses `IBrouter` cannot be
+classified reliably, and interactive Blazor reports **browser-button** Back/Forward as intercepted
+navigations, so those may surface as `Push` to guards/hooks. The built-in view-transition
+animations are unaffected - their direction comes from the browser's own `popstate` signal, so the
+back-button motion mirrors correctly regardless.
+
## Active links
```razor
@@ -177,6 +476,111 @@ builder.Services.AddBitBrouterServices(o =>
Users
```
+## Scroll & focus management
+
+After each successful navigation Brouter runs a few DOM effects, all configured on `BrouterOptions`
+and applied once the matched route is committed to the DOM (so `#fragment` and focus selectors resolve
+against the new page). During static prerender these are skipped - there is no DOM/JS to act on.
+
+```csharp
+builder.Services.AddBitBrouterServices(o =>
+{
+ // Scroll the window to the top on navigation. Default: BrouterScrollMode.None.
+ o.ScrollBehavior = BrouterScrollMode.ToTop;
+
+ // Scroll a URL fragment into view: navigating to /docs#install lands on the #install
+ // element (and moves focus to it). A found fragment target wins over ScrollBehavior.
+ // Only acts when the URL carries a fragment. Default: true.
+ o.ScrollToFragment = true;
+
+ // Remember each page's scroll position and restore it on Back/Forward, like native browsers
+ // and real SPA routers. A NEW navigation still uses ScrollBehavior (e.g. ToTop); only a
+ // Back/Forward to a previously-visited URL restores where the user left off. Enabling this
+ // takes over the browser's own restoration (history.scrollRestoration = "manual"). Default: false.
+ o.RestoreScrollPosition = true;
+
+ // Where restored positions are stored. Default Memory (lost on a full reload). Use SessionStorage
+ // (recommended: per-tab, auto-cleared on tab close) or LocalStorage (survives restarts, shared
+ // across tabs) to make positions survive a reload. No effect unless RestoreScrollPosition is on;
+ // falls back to in-memory if the store is unavailable (private mode, quota).
+ o.ScrollPositionStorage = BrouterScrollPositionStorage.SessionStorage;
+
+ // Move focus to this selector after navigation so assistive technologies announce the new
+ // page instead of leaving focus on the activated link - a WCAG-relevant concern for an SPA
+ // router, mirroring Blazor's . A non-focusable target gets tabindex="-1"
+ // so it can receive programmatic focus without joining the Tab order. Default: null (off).
+ o.FocusOnNavigateSelector = "h1";
+});
+```
+
+Precedence when several apply: if a fragment target resolves, it scrolls into view and takes focus, and
+no further scroll or focus handling runs (so `FocusOnNavigateSelector` is not applied on that navigation).
+Otherwise, on a Back/Forward with a remembered position that position is restored, else scroll-to-top runs;
+and only in these non-fragment cases does `FocusOnNavigateSelector` (if set) then receive focus.
+
+## View transitions
+
+Enable the browser's View Transitions API to animate between pages:
+
+```csharp
+builder.Services.AddBitBrouterServices(o =>
+{
+ o.ViewTransitions = true;
+});
+```
+
+**Beautiful by default.** With `ViewTransitions` on, Brouter ships polished, direction-aware
+animations out of the box (`o.ViewTransitionDefaultAnimations`, enabled by default):
+
+- a forward navigation (push) glides the new page in;
+- Back/Forward (pop) **mirrors the motion**, so going back *feels* like going back;
+- a replace does a quick in-place fade;
+- shared-element morphs get a springy glide;
+- `prefers-reduced-motion` swaps the slides for gentle opacity-only crossfades (and stills the
+ morphs) - navigation keeps visual feedback without movement. Note that OS accessibility settings
+ feed this media query: on Windows, turning off Settings > Accessibility > Visual effects >
+ **Animation effects** makes every browser on the machine report `reduce`. Because that setting is
+ often off for *performance* reasons (VMs, remote desktops) rather than user preference, you can
+ bypass it with `o.ViewTransitionRespectReducedMotion = false` - think twice, though: for
+ motion-sensitive users `reduce` is a genuine request.
+
+The defaults live in the CSS layer `bit-brouter`, so **any unlayered `::view-transition-*` rule in
+your own CSS overrides them automatically** - customize without specificity fights, or set
+`o.ViewTransitionDefaultAnimations = false` to opt out entirely. The current direction is exposed as
+`data-brouter-nav="push|replace|pop"` on `` for your CSS to key off.
+
+Per-element morphs are standard CSS - the same `view-transition-name` on both pages makes the
+element morph between them (see the demo's `/gallery` page for a tile-to-hero showcase):
+
+```css
+.post-title { view-transition-name: post-title; }
+```
+
+Brouter splits the transition around Blazor's async render: the outgoing page is snapshotted (and
+the snapshot is awaited - critical for correct morphs) right before the new route renders, and the
+transition completes once the new DOM (including scroll/focus effects) has landed. On browsers
+without `document.startViewTransition`, during prerender, and in non-browser hosts the whole thing
+is inert - navigation behaves exactly as with the option off.
+
+> **Troubleshooting: animations (and other JS features) suddenly stop working in dev.** When
+> Bit.Brouter's `bit-brouter.js` changes (package update, or a local rebuild of the library), the
+> host app's cached compressed static-web-asset manifest can go stale, and the browser receives the
+> module as an empty 200 response - transitions, scroll management and link preloading all silently
+> stop while navigation keeps working. Fix: **Rebuild the host project once** (`dotnet build
+> -t:Rebuild`, or Build > Rebuild in the IDE). `curl` shows the file fine, which makes this
+> maddening to diagnose - check the response with an `Accept-Encoding: gzip` header instead.
+
+## Not found handling (.NET 10)
+
+On net10.0, Brouter participates in the framework's not-found contract:
+
+- Application code calling `NavigationManager.NotFound()` (e.g. a page whose entity lookup failed)
+ flows through Brouter: the `OnNotFound` hook fires, then the `NotFound` URL redirect or inline
+ `NotFoundContent` renders - the URL stays put, mirroring the built-in router.
+- When Brouter itself matches nothing during **static SSR**, it calls `NavigationManager.NotFound()`
+ so the response carries a real **HTTP 404** (and drives `UseStatusCodePagesWithReExecute` when
+ configured) instead of a 200 with fallback HTML.
+
## Global hooks
```razor
@@ -213,40 +617,369 @@ builder.Services.AddBitBrouterServices(o =>
}
```
+Besides the `IBrouter` events, the `Brouter` component itself takes two async hooks:
+`OnMatch` (fired with the winning `Broute` whenever a route matches) and `OnNotFound` (fired with
+the `BrouterLocation` when nothing matches, before the `NotFound` redirect/fallback applies).
+
## Nested routes
```razor
-
-
-
+
+
+ Edit user [@p["id"]]
-
-
+
+
```
```razor
-
+
Dashboard
-
+
-
+
```
-## Custom constraints
+### Pathless group routes
+
+Share behavior across routes without inventing a URL segment:
+
+```razor
+
+
+
+
+
+
+
+
+
+```
+
+`/dashboard` and `/audit` match exactly as written - the group is invisible in the URL, in
+specificity and in depth tiebreaks - but its guard, loader, layout and `ErrorContent` apply to both
+children. Sibling groups coexist freely (they never register as matchable templates).
+
+### Lazy route loading
+
+```razor
+...
+
+@code {
+ [Inject] LazyAssemblyLoader Lazy { get; set; } = default!;
+
+ private async ValueTask?> LoadRouteAssemblies(BrouterNavigationContext ctx)
+ {
+ if (ctx.To.Path.StartsWith("/reports"))
+ return await Lazy.LoadAssembliesAsync(["Reports.wasm"]);
+ return null;
+ }
+}
+```
+
+The hook runs before matching on every navigation (initial deep links included). Returned
+assemblies are scanned for `@page`/`[Route]` components and registered *within the same
+navigation*, so the URL that triggered the load lands on the freshly-loaded page - no
+grow-a-list-and-re-render dance.
+
+### Functional query updates
```csharp
-BrouterConstraints.Register("slug",
- new BrouterTypeRouteConstraint((string s, out string r) =>
+// From /q?filter=red&sort=name&page=1:
+brouter.NavigateWithQuery(q => q.Set("page", 2)); // -> /q?filter=red&sort=name&page=2
+brouter.NavigateWithQuery(q => q.Remove("filter")); // untouched params always survive
+brouter.NavigateWithQuery(q => q.SetAll("tag", ["a", "b"])); // -> ?tag=a&tag=b
+```
+
+Values are formatted invariantly (same rules as `ResolveUrl`), null removes a parameter, and the
+navigation replaces the history entry by default (query-as-UI-state); pass `replace: false` to push.
+
+## Typed routes (source generator)
+
+Add the `Bit.Brouter.Generators` package and every route declared in your `.razor` files - `@page`
+directives, `@attribute [Route(...)]`, and literal (nested) `` trees - gets a
+compile-time-safe URL builder on a generated `BrouterRoutes` class in your root namespace:
+
+```razor
+@* declared somewhere:
+
+ @page "/files/{**path}" *@
+
+Counter
+
+@code {
+ void Go() => brouter.Navigate(BrouterRoutes.ProfileByUsername("saleh", query: "tab=posts"));
+ string FileUrl() => BrouterRoutes.Files("docs/readme.md");
+ void ByName() => brouter.NavigateToName(BrouterRoutes.Names.Counter,
+ new Dictionary { ["init"] = 5 });
+}
+```
+
+- Constraints become parameter types (`{init:int}` → `int init`, `{id:guid}` → `Guid id`; the last
+ constraint wins, matching the matcher), optionals become optional arguments, catch-alls become
+ path strings split and escaped per segment.
+- Methods are named from the route's `Name` when present (named routes always own their identifier)
+ or from the template's literals + `By{Param}` suffixes; every method takes a trailing
+ `string? query = null`.
+- Values are escaped and formatted with the router's exact invariant rules, so a generated URL
+ always round-trips through its own template.
+- Skipped by design: dynamic paths (`Path="@expr"`) and their subtrees, `RedirectTo` routes,
+ literal-wildcard templates (`/*/x`), and `Group` routes (their children generate normally).
+
+## Named outlets
+
+One route can fill several regions of its parent's layout. The parent declares outlets; each child
+route provides its main content plus optional named `BrouterView` fragments:
+
+```razor
+
+
+ @* primary: the child's Content/Component *@
+ @* named: the child's matching BrouterView *@
+
+
+
+
+
+
+
+
+ @* no view -> sidebar renders empty *@
+
+
+```
+
+Named views receive the route's parameters (the `Context`) and see its data/meta cascades. Unlike
+Angular's secondary outlets there is no URL serialization - the named regions always follow the
+primary match, which is the common layout case.
+
+## Keep-alive routes
+
+```razor
+
+ @* filters, scroll, half-typed input survive navigation *@
+
+```
+
+When the user navigates away, the rendered component stays mounted inside a hidden wrapper instead
+of being disposed; navigating back flips it visible again with all its state intact - including
+through a parent's `BrouterOutlet` when switching between sibling routes. Opt-in per route; combine
+with `StaleTime` for instant, fully-warm Back navigation.
+
+### Activate / deactivate lifecycle
+
+A kept component keeps *running* while hidden - timers, polling and live subscriptions all keep
+firing, and any `StateHasChanged` re-renders it off-screen (on Blazor Server that is a diff over the
+wire for a page nobody is looking at). Consume the cascaded `BrouterKeepAliveContext` to pause that
+work while inactive and resume (or refresh) when the page is shown again - the equivalent of Vue's
+`onActivated`/`onDeactivated`:
+
+```razor
+@implements IDisposable
+@code {
+ [CascadingParameter] BrouterKeepAliveContext? KeepAlive { get; set; }
+ private Timer? _poll;
+
+ protected override void OnParametersSet()
{
- r = s;
- return s.Length >= 3 && s.All(c => char.IsLetterOrDigit(c) || c == '-');
- }));
+ // A fresh context instance is cascaded on every activate/deactivate flip, so this runs on
+ // each transition. Pause background work while hidden; resume/refresh when shown again.
+ if (KeepAlive?.IsActive == false) _poll?.Change(Timeout.Infinite, Timeout.Infinite);
+ else _poll?.Change(0, 5000);
+ }
+
+ public void Dispose() => _poll?.Dispose();
+}
+```
+
+### Per-parameter caching with `KeepAliveMax`
+
+By default (`KeepAliveMax` unset, i.e. 1) retention is **per route**: a parameterized keep-alive
+route (e.g. `/item/{id}`) keeps a *single* live instance that re-binds to each new value - state
+carries *across* parameter changes. Set `KeepAliveMax` above 1 to cache **per parameter values**
+instead:
+
+```razor
+
+
+
+```
+
+Now `/item/1 → /item/2 → /item/1` keeps two separate instances and returning to each resumes its
+exact state (parameters and loader data stay frozen on hidden instances). When more than
+`KeepAliveMax` parameter sets have been visited, the least-recently-used hidden instance is evicted
+(disposed). The cache key is the route's template parameter values only - query-string variations
+share one instance. `BrouterOptions.DefaultKeepAliveMax` sets the default for routes that don't
+declare their own.
+
+### What it keeps, and for how long
+
+- **The loader still runs on return.** Keep-alive preserves component state, but a return navigation
+ re-matches the route and re-runs its `Loader` unless a `StaleTime` cache hit covers it. Pair the
+ two if you also want the data reused.
+- **Lifetime is bounded by the hosting layout.** State survives sibling switches under a layout and
+ navigations away and back at the *top* level, but not the hosting layout's own unmount, nor a full
+ page reload. To extend a nested route's retention across leaving its parent, mark the parent
+ `KeepAlive` too (at the cost of keeping the whole subtree hidden-mounted).
+
+### Cost and eviction
+
+Each kept page holds its C# state **and** its hidden DOM for as long as it is retained, so the cost
+is memory + DOM node count per kept page (measured by the `Tests/Bit.Brouter.Benchmarks` project).
+Retention is inherently bounded - one instance per `KeepAlive` route by default, up to
+`KeepAliveMax` for per-parameter caching - but you can release it on demand:
+
+```csharp
+@inject IBrouter brouter
+...
+brouter.ClearKeepAlive(); // dispose every retained (hidden) page; the visible one stays
+```
+
+Call it on sign-out, under memory pressure, or after invalidating the state those pages hold; the
+next visit to a dropped route recreates it fresh.
+
+> Notes: turning on `KeepAlive` wraps the route's inline content in a `
` (the stable element
+> that preserves the subtree), which can affect direct-child CSS selectors. Retention applies to a
+> route's primary content; named-outlet (`BrouterView`) fragments are not separately kept.
+
+## Attribute-route / `@page` discovery
+
+Routes don't have to be hand-declared in one tree. Point `Brouter` at your assemblies and it discovers
+components annotated with `[Route]` (which is what `@page` compiles to), matching them alongside any
+hand-declared `` children. This keeps route templates colocated with their pages, supports Razor
+class libraries, and works with lazily-loaded assemblies.
+
+```razor
+@* Counter.razor - the route lives next to the page *@
+@page "/counter/{start:int}"
+
+
Count: @Start
+
+@code {
+ [Parameter] public int Start { get; set; } // bound from the {start:int} segment
+ [Parameter, SupplyParameterFromQuery] public string? Tab { get; set; } // bound from ?tab=
+}
+```
+
+```razor
+
+ @* Optional: hand-declared routes still work and win ties over discovered ones *@
+
+
+
+@code {
+ // Grow this list (with a re-render) as assemblies load to register their routes at runtime.
+ private readonly List _lazyLoaded = new();
+}
+```
+
+A hand-declared `` with the exact template of a discovered `@page` shadows it (useful to attach a
+`Guard`/`Loader` to an existing page) - this is the one duplicate-template pairing that isn't rejected as
+ambiguous. Duplicating a template across two `@page` components, or across two hand-declared routes, throws.
+
+Discovered routes bind their `[Parameter]` properties by name (Blazor-style) - route segments to plain
+`[Parameter]` properties and query values to `[SupplyParameterFromQuery]` (or `[BrouterQuery]`). To get the
+same by-name binding on a hand-declared route, set `BindComponentParametersByName="true"` on the ``.
+
+> Discovery reflects over the given assemblies, so - like the built-in Blazor `Router` - keep your routable
+> components preserved when trimming.
+
+## Performance & scalability
+
+Brouter is declarative: **every route is a live component instance**. Each hand-declared `` - and
+each attribute-discovered route, which Brouter emits as a synthetic `` - is a `ComponentBase`
+mounted in the render tree for the lifetime of the `Brouter`, carrying its own renderer, cached
+template/parameter dictionaries and cascading-value subscriptions. This is what powers nested layouts,
+per-route guards/loaders and hierarchical matching, but it differs from the built-in Blazor `Router`,
+which keeps routes as a plain `RouteTable` (data, not components) and instantiates only the *matched*
+component.
+
+Two costs to keep separate:
+
+- **Match cost** (per navigation) is handled: a first-segment index means matching does not do a full
+ `O(routes)` scan on every navigation - only routes whose first template segment can match the URL's
+ first segment (plus the usually-small set of parameter/wildcard/empty-template routes) are considered.
+- **Instantiation cost** (steady state) is *not* reduced by that index. An app with several hundred pages
+ keeps several hundred `Broute` instances alive. Unmatched routes render nothing (their `BuildRenderTree`
+ short-circuits on the match flag), so this is a memory/instance-count cost, not a per-render one.
+
+For typical apps (tens of routes) this is a non-issue. The `Tests/Bit.Brouter.Benchmarks` project
+measures it directly (Brouter vs a RouteTable baseline that instantiates only the matched component).
+Indicative numbers (.NET 10, Release): each live route costs on the order of **3-6 KB** of retained
+managed heap, so **~500 routes** adds roughly **2.5 MB** of memory and **~4 ms** of startup over the
+data-table approach, growing linearly (~5.6 MB / ~8 ms at 1000 routes). Material for a very large
+all-`@page` app; negligible otherwise. Run `dotnet run -c Release` in that project for numbers on your
+own hardware and route counts.
+
+If you have **hundreds of pages** and care about startup/memory:
+
+- **Benchmark at your real route count** (see `Tests/Bit.Brouter.Benchmarks`) before treating Brouter
+ as a drop-in for a very large app.
+- **Split routes across lazily-loaded assemblies** and add them to `AdditionalAssemblies` as they load,
+ so routes for pages the user hasn't reached yet aren't mounted up front.
+
+## Prerender state bridging
+
+Under SSR/prerender, a route `Loader` runs on the server to produce the prerendered HTML, then the component
+becomes interactive and its lifecycle runs again. By default the loader would run a second time (double-fetch).
+Enable `PersistLoaderState` to capture each loader result during prerender (via `PersistentComponentState`) and
+restore it on the interactive pass instead of re-fetching:
+
+```csharp
+builder.Services.AddBitBrouterServices(o =>
+{
+ o.PersistLoaderState = true;
+});
+```
+
+Restoration degrades gracefully: if a value can't be rehydrated the loader simply runs again, so a mismatch
+never breaks navigation.
+
+> This serializes loader results with reflection-based `System.Text.Json`, which isn't trimming/AOT-safe for
+> arbitrary types - enable it when your loader data types are JSON-serializable and preserved under trimming.
+> For full trimming/AOT safety, supply a source-generated context:
+>
+> ```csharp
+> [JsonSerializable(typeof(User))]
+> [JsonSerializable(typeof(Post))]
+> partial class AppJsonContext : JsonSerializerContext { }
+>
+> builder.Services.AddBitBrouterServices(o =>
+> {
+> o.PersistLoaderState = true;
+> o.LoaderStateTypeInfoResolver = AppJsonContext.Default;
+> });
+> ```
+>
+> Types the resolver doesn't cover degrade gracefully: their results aren't persisted and the loader
+> simply re-runs on the interactive pass.
+
+## Custom constraints
+
+Register custom constraints at startup on `BrouterOptions.Constraints`. They are scoped to the DI
+container that owns the options, so separate apps in one process (and parallel test classes) stay
+isolated.
+
+```csharp
+builder.Services.AddBitBrouterServices(o =>
+{
+ o.Constraints.Register("slug",
+ new BrouterTypeRouteConstraint((string s, out string r) =>
+ {
+ r = s;
+ return s.Length >= 3 && s.All(c => char.IsLetterOrDigit(c) || c == '-');
+ }));
+});
```
```razor
-
+
```
+
+> Built-in constraints (`int`, `bool`, `guid`, `long`, `float`, `double`, `decimal`, `datetime`) are
+> always available and need no registration.
diff --git a/src/Brouter/Tests/Bit.Brouter.Benchmarks/BenchmarkComponents.cs b/src/Brouter/Tests/Bit.Brouter.Benchmarks/BenchmarkComponents.cs
new file mode 100644
index 0000000000..ea5c368837
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Benchmarks/BenchmarkComponents.cs
@@ -0,0 +1,86 @@
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace Bit.Brouter.Benchmarks;
+
+///
+/// The trivial page each route renders when matched. Intentionally cheap so the benchmark measures
+/// routing/instantiation overhead, not page content.
+///
+public sealed class BenchPage : ComponentBase
+{
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "span");
+ builder.AddContent(1, "page");
+ builder.CloseElement();
+ }
+}
+
+///
+/// Scenario A - the current Brouter model: every route is a live component
+/// mounted in the render tree. Emits RouteCount hand-declared <Broute> children,
+/// which is the same shape (and the same per-route cost) as the synthetic Broute that Brouter emits
+/// per attribute-discovered route - so measuring this measures the discovered-route cost too.
+///
+public sealed class BrouterBenchHost : ComponentBase
+{
+ [Parameter] public int RouteCount { get; set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenComponent(0);
+ builder.AddAttribute(1, nameof(Brouter.ChildContent), (RenderFragment)(b =>
+ {
+ var seq = 0;
+ for (int i = 0; i < RouteCount; i++)
+ {
+ b.OpenComponent(seq++);
+ b.SetKey(i);
+ b.AddAttribute(seq++, nameof(Broute.Path), $"/page/{i}");
+ b.AddAttribute(seq++, nameof(Broute.Component), typeof(BenchPage));
+ b.CloseComponent();
+ }
+ }));
+ builder.CloseComponent();
+ }
+}
+
+///
+/// Scenario B - the baseline: models the built-in Blazor Router's architecture. The routes live
+/// as plain data (a template-to-type table), and only the single matched component is instantiated on
+/// render. No component is mounted per route. This is the same shape the "lazy discovered routes" design
+/// would take, so the gap between this and is the instantiation cost the
+/// review flagged - measurable at any route count without needing hundreds of real @page types.
+///
+public sealed class RouteTableHost : ComponentBase
+{
+ [Parameter] public int RouteCount { get; set; }
+ [Inject] public NavigationManager Nav { get; set; } = default!;
+
+ // The "RouteTable": route templates as data, not components. Built once per RouteCount.
+ private Dictionary _table = new(StringComparer.Ordinal);
+ private int _builtFor = -1;
+
+ protected override void OnParametersSet()
+ {
+ if (_builtFor == RouteCount) return;
+ _table = new Dictionary(StringComparer.Ordinal);
+ for (int i = 0; i < RouteCount; i++) _table[$"/page/{i}"] = typeof(BenchPage);
+ _builtFor = RouteCount;
+ }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ var path = "/" + Nav.ToBaseRelativePath(Nav.Uri);
+ var q = path.IndexOf('?');
+ if (q >= 0) path = path[..q];
+
+ // Instantiate only the matched component - exactly what RouteView does in the built-in Router.
+ if (_table.TryGetValue(path, out var matched))
+ {
+ builder.OpenComponent(0, matched);
+ builder.CloseComponent();
+ }
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Benchmarks/Bit.Brouter.Benchmarks.csproj b/src/Brouter/Tests/Bit.Brouter.Benchmarks/Bit.Brouter.Benchmarks.csproj
new file mode 100644
index 0000000000..bed8df2190
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Benchmarks/Bit.Brouter.Benchmarks.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Benchmarks/Harness.cs b/src/Brouter/Tests/Bit.Brouter.Benchmarks/Harness.cs
new file mode 100644
index 0000000000..b9f4f0bdd1
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Benchmarks/Harness.cs
@@ -0,0 +1,95 @@
+using System.Diagnostics;
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Brouter.Benchmarks;
+
+/// One scenario's measured cost at a given route count.
+public readonly record struct Sample(double RenderMs, double AllocMB, double RetainedKB);
+
+///
+/// Renders each scenario in an isolated bUnit host and measures three things per route count:
+/// - render time (instantiation + initial match),
+/// - bytes allocated during that render, and
+/// - managed heap retained afterwards while the rendered tree is held alive (the "permanently
+/// alive" cost the review is about).
+///
+/// Each measurement uses its own fresh TestContext so nothing carries over between route counts,
+/// and every route count is measured over several trials (after warmup) with the median reported,
+/// to damp GC/JIT noise. Absolute numbers include a fixed bUnit renderer/host overhead that is
+/// identical for both scenarios - the signal is the gap between the two and how it grows with N.
+///
+public static class Harness
+{
+ public static Sample MeasureBrouter(int routeCount, int warmup, int trials)
+ => Measure(routeCount, warmup, trials, brouter: true);
+
+ public static Sample MeasureRouteTable(int routeCount, int warmup, int trials)
+ => Measure(routeCount, warmup, trials, brouter: false);
+
+ private static Sample Measure(int routeCount, int warmup, int trials, bool brouter)
+ {
+ // Navigate to a route roughly in the middle so a match actually happens (and, for the
+ // RouteTable baseline, one page is instantiated) - matching the Brouter scenario's work.
+ var path = $"/page/{routeCount / 2}";
+
+ for (int i = 0; i < warmup; i++) RenderOnce(routeCount, brouter, path, out _, out _, out _);
+
+ var times = new double[trials];
+ var allocs = new double[trials];
+ var retained = new double[trials];
+ for (int i = 0; i < trials; i++)
+ {
+ RenderOnce(routeCount, brouter, path, out times[i], out allocs[i], out retained[i]);
+ }
+
+ return new Sample(Median(times), Median(allocs), Median(retained));
+ }
+
+ private static void RenderOnce(int routeCount, bool brouter, string path,
+ out double renderMs, out double allocMB, out double retainedKB)
+ {
+ using var ctx = new Bunit.TestContext();
+ ctx.JSInterop.Mode = JSRuntimeMode.Loose;
+ ctx.Services.AddBitBrouterServices();
+
+ var nav = ctx.Services.GetRequiredService();
+ nav.NavigateTo("http://localhost" + path);
+
+ Settle();
+ var memBefore = GC.GetTotalMemory(forceFullCollection: true);
+ var allocBefore = GC.GetTotalAllocatedBytes(precise: true);
+
+ var sw = Stopwatch.StartNew();
+ object cut = brouter
+ ? ctx.RenderComponent(p => p.Add(x => x.RouteCount, routeCount))
+ : ctx.RenderComponent(p => p.Add(x => x.RouteCount, routeCount));
+ sw.Stop();
+
+ var allocAfter = GC.GetTotalAllocatedBytes(precise: true);
+ // Retained: force a collection with the rendered tree still rooted (via `cut`), so only the
+ // memory that survives - the mounted components and renderer bookkeeping - is counted.
+ var memAfter = GC.GetTotalMemory(forceFullCollection: true);
+ GC.KeepAlive(cut);
+
+ renderMs = sw.Elapsed.TotalMilliseconds;
+ allocMB = (allocAfter - allocBefore) / (1024.0 * 1024.0);
+ retainedKB = Math.Max(0, memAfter - memBefore) / 1024.0;
+ }
+
+ private static void Settle()
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+ }
+
+ private static double Median(double[] values)
+ {
+ var copy = (double[])values.Clone();
+ Array.Sort(copy);
+ var mid = copy.Length / 2;
+ return copy.Length % 2 == 1 ? copy[mid] : (copy[mid - 1] + copy[mid]) / 2.0;
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Benchmarks/Program.cs b/src/Brouter/Tests/Bit.Brouter.Benchmarks/Program.cs
new file mode 100644
index 0000000000..da5598e5ff
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Benchmarks/Program.cs
@@ -0,0 +1,64 @@
+using System.Globalization;
+using Bit.Brouter.Benchmarks;
+
+// Route counts to sweep. Override on the command line, e.g.:
+// dotnet run -c Release -- 100 250 500 1000
+// The review specifically calls out benchmarking "at 200-500 routes", which the default sweep covers.
+int[] routeCounts = args.Length > 0
+ ? args.Select(a => int.Parse(a, CultureInfo.InvariantCulture)).ToArray()
+ : [50, 100, 200, 500, 1000];
+
+const int warmup = 2;
+const int trials = 7;
+
+Console.WriteLine();
+Console.WriteLine("Bit.Brouter route-scalability benchmark");
+Console.WriteLine("=======================================");
+Console.WriteLine("Scenario A (Brouter) : every route is a live component instance.");
+Console.WriteLine("Scenario B (RouteTable) : routes as data; only the matched component is instantiated");
+Console.WriteLine(" (models the built-in Blazor Router).");
+Console.WriteLine($"warmup={warmup} trials={trials} (median reported) | build MUST be Release for meaningful numbers");
+#if DEBUG
+Console.WriteLine();
+Console.WriteLine(" !! WARNING: running a DEBUG build. Re-run with `dotnet run -c Release` for real numbers. !!");
+#endif
+Console.WriteLine();
+
+// Header
+Console.WriteLine(
+ "{0,7} | {1,-28} | {2,-28} | {3}",
+ "routes", "Brouter (A)", "RouteTable (B)", "retained delta");
+Console.WriteLine(
+ "{0,7} | {1,-28} | {2,-28} | {3}",
+ "", "render / alloc / retained", "render / alloc / retained", "A - B (~per route)");
+Console.WriteLine(new string('-', 108));
+
+foreach (var n in routeCounts)
+{
+ var a = Harness.MeasureBrouter(n, warmup, trials);
+ var b = Harness.MeasureRouteTable(n, warmup, trials);
+
+ var retainedDelta = a.RetainedKB - b.RetainedKB;
+ var perRouteBytes = n > 0 ? retainedDelta * 1024.0 / n : 0;
+
+ Console.WriteLine(
+ "{0,7} | {1,-28} | {2,-28} | {3,8:0.0} KB (~{4:0} B/route)",
+ n,
+ Fmt(a),
+ Fmt(b),
+ retainedDelta,
+ perRouteBytes);
+}
+
+Console.WriteLine();
+Console.WriteLine("Reading the results:");
+Console.WriteLine(" - 'render' : median wall-clock to instantiate + do the first match (ms).");
+Console.WriteLine(" - 'alloc' : bytes allocated during that render (MB).");
+Console.WriteLine(" - 'retained' : managed heap still held after render, tree kept alive (KB).");
+Console.WriteLine(" - The A-B retained delta is the steady-state cost of keeping every route as a live");
+Console.WriteLine(" component. Divided by route count it estimates the per-route memory overhead.");
+Console.WriteLine(" - Absolute values include a fixed bUnit renderer/host overhead present in both");
+Console.WriteLine(" columns; compare the two columns and how the delta scales with route count.");
+Console.WriteLine();
+
+static string Fmt(Sample s) => $"{s.RenderMs,6:0.0}ms {s.AllocMB,5:0.0}MB {s.RetainedKB,7:0}KB";
diff --git a/src/Brouter/Tests/Bit.Brouter.Benchmarks/README.md b/src/Brouter/Tests/Bit.Brouter.Benchmarks/README.md
new file mode 100644
index 0000000000..59a31d4e05
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Benchmarks/README.md
@@ -0,0 +1,78 @@
+# Bit.Brouter route-scalability benchmark
+
+A dedicated console harness for the one scalability question the code review raised:
+
+> Every route in Brouter is a live `Broute` `ComponentBase` mounted in the render tree (including the
+> synthetic ones emitted per attribute-discovered route). Unlike the built-in Blazor `Router` - which
+> keeps routes as a `RouteTable` (data) and instantiates only the *matched* component - an app with
+> several hundred pages keeps several hundred `Broute` components permanently alive. Worth benchmarking
+> against the built-in router at 200-500 routes before calling it production-ready for large apps.
+
+This project measures that cost so the trade-off is backed by numbers, not intuition.
+
+## What it compares
+
+| Scenario | What it renders | Models |
+|----------|-----------------|--------|
+| **A - Brouter** | `` with *N* hand-declared `` children | The current Brouter model. A hand-declared `` has the same per-route cost as the synthetic one Brouter emits per discovered route, so this measures the discovered-route case too. |
+| **B - RouteTable** | *N* routes as a `template → type` dictionary; only the single matched component is instantiated | The built-in Blazor `Router` architecture (and the "lazy discovered routes" design we discussed). Scalable to any *N* without needing hundreds of real `@page` types. |
+
+The **gap between A and B** is the instantiation / steady-state cost the review flagged.
+
+> Why not benchmark the real built-in `Router`? It discovers routes by reflecting over an assembly for
+> `[Route]` types, so driving it at 500 routes would require synthesizing 500 real routable types
+> (`Reflection.Emit`). Scenario B reproduces its *architecture* (route table + instantiate-only-matched)
+> faithfully and at arbitrary *N*, which is what the comparison needs.
+
+## Running it
+
+```bash
+# From this folder. Release is required - a Debug build's numbers are meaningless.
+dotnet run -c Release
+
+# Custom route-count sweep (defaults to 50 100 200 500 1000):
+dotnet run -c Release -- 100 250 500
+```
+
+It renders each scenario in an isolated [bUnit](https://bunit.dev) host (bUnit is used purely as a
+lightweight Blazor renderer + fakes for `NavigationManager` / JS interop - not as a test framework),
+over several trials after warmup, and reports the median.
+
+## What it measures, per route count
+
+- **render** - wall-clock to instantiate the tree and do the first match (ms).
+- **alloc** - bytes allocated during that render (MB).
+- **retained** - managed heap still held *after* the render while the rendered tree is kept alive (KB).
+ This is the "permanently alive" cost the review is about.
+
+Absolute values include a fixed bUnit renderer/host overhead present in **both** columns; the signal is
+the **difference** between the columns and how it **scales** with route count.
+
+## Indicative results
+
+Captured on .NET 10, Release, a developer laptop. Treat as relative/indicative - absolute numbers are
+machine-dependent; re-run locally for your own hardware.
+
+| routes | Brouter render | Brouter retained | RouteTable retained | retained delta (A−B) | ~per route |
+|-------:|---------------:|-----------------:|--------------------:|---------------------:|-----------:|
+| 50 | 1.2 ms | 234 KB | 76 KB | ~158 KB | ~3.2 KB |
+| 100 | 1.6 ms | 378 KB | 80 KB | ~298 KB | ~3.0 KB |
+| 200 | 2.4 ms | 764 KB | 91 KB | ~673 KB | ~3.4 KB |
+| 500 | 4.5 ms | 2623 KB | 116 KB | ~2507 KB | ~5.1 KB |
+| 1000 | 7.8 ms | 5742 KB | 163 KB | ~5579 KB | ~5.7 KB |
+
+### Takeaways
+
+- **The review's concern is real and grows linearly.** Brouter's render time and retained memory both
+ scale with route count; the RouteTable baseline stays essentially flat (only the string table grows).
+- **But the magnitude is bounded.** Each live `Broute` costs on the order of **3-6 KB** of retained
+ managed heap. At **500 routes** that's roughly **2.5 MB** extra memory and **~4 ms** extra startup;
+ at **1000 routes**, ~5.6 MB and ~8 ms. Material for a very large all-`@page` app, negligible for a
+ typical one (tens of routes).
+- **This isolates instantiation cost, which the first-segment match index does not address.** Matching
+ cost is a separate axis (and is already optimized); see the `GetRouteIndex` note in `Brouter.cs` and
+ the "Performance & scalability" section of the main README.
+
+Use these numbers to decide whether the "lazy discovered routes" optimization (instantiate only the
+matched route, keep the rest as data) is worth the added matcher complexity **for your route counts** -
+which is exactly the decision the review asked to defer until measured.
diff --git a/src/Brouter/Tests/Bit.Brouter.Generators.Tests/Bit.Brouter.Generators.Tests.csproj b/src/Brouter/Tests/Bit.Brouter.Generators.Tests/Bit.Brouter.Generators.Tests.csproj
new file mode 100644
index 0000000000..235b323408
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Generators.Tests/Bit.Brouter.Generators.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ $(NoWarn);CS1591
+ true
+ false
+ true
+ ..\..\..\AssemblyOriginatorKeyFile.snk
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Generators.Tests/BrouterRoutesGeneratorTests.cs b/src/Brouter/Tests/Bit.Brouter.Generators.Tests/BrouterRoutesGeneratorTests.cs
new file mode 100644
index 0000000000..c175654b3a
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Generators.Tests/BrouterRoutesGeneratorTests.cs
@@ -0,0 +1,205 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using static Bit.Brouter.Generators.Tests.GeneratorTestHarness;
+
+namespace Bit.Brouter.Generators.Tests;
+
+[TestClass]
+public class BrouterRoutesGeneratorTests
+{
+ [TestMethod]
+ public void Page_directive_with_constraint_generates_a_typed_builder()
+ {
+ var (source, asm) = Run(("Counter.razor", """
+ @page "/counter/{start:int}"
+
Counter
+ """));
+
+ StringAssert.Contains(source, "public static string CounterByStart(int @start");
+ Assert.AreEqual("/counter/42", Invoke(asm, "CounterByStart", 42, Type.Missing));
+ }
+
+ [TestMethod]
+ public void Nested_Broute_tags_compose_the_full_template()
+ {
+ var (_, asm) = Run(("Routes.razor", """
+
+
+
+ edit
+
+
+
+ """));
+
+ Assert.AreEqual("/users/7/edit", Invoke(asm, "UsersEditById", 7, Type.Missing));
+ Assert.AreEqual("/users", Invoke(asm, "Users", Type.Missing));
+ }
+
+ [TestMethod]
+ public void Named_route_names_the_method_and_emits_a_Names_constant()
+ {
+ var (_, asm) = Run(("Routes.razor", """
+
+
+
+ """));
+
+ Assert.AreEqual("/users/42", Invoke(asm, "User", 42, Type.Missing));
+ Assert.AreEqual("user", NameConstant(asm, "User"));
+ }
+
+ [TestMethod]
+ public void Optional_parameter_is_omittable()
+ {
+ var (_, asm) = Run(("Profile.razor", """
+ @page "/profile/{username?}"
+ """));
+
+ Assert.AreEqual("/profile", Invoke(asm, "ProfileByUsername", Type.Missing, Type.Missing));
+ Assert.AreEqual("/profile/saleh", Invoke(asm, "ProfileByUsername", "saleh", Type.Missing));
+ }
+
+ [TestMethod]
+ public void CatchAll_splits_and_escapes_per_segment()
+ {
+ var (_, asm) = Run(("Files.razor", """
+ @page "/files/{**path}"
+ """));
+
+ Assert.AreEqual("/files", Invoke(asm, "Files", Type.Missing, Type.Missing));
+ Assert.AreEqual("/files/docs/a%20b", Invoke(asm, "Files", "docs/a b", Type.Missing));
+ }
+
+ [TestMethod]
+ public void Values_are_escaped_and_formatted_invariantly()
+ {
+ var (_, asm) = Run(("Routes.razor", """
+
+
+
+
+ """));
+
+ Assert.AreEqual("/tag/c%23%20rocks", Invoke(asm, "TagByName", "c# rocks", Type.Missing));
+ Assert.AreEqual("/flag/true", Invoke(asm, "FlagByOn", true, Type.Missing));
+ }
+
+ [TestMethod]
+ public void Query_argument_appends_with_a_question_mark_either_way()
+ {
+ var (_, asm) = Run(("Home.razor", """
+ @page "/home"
+ """));
+
+ Assert.AreEqual("/home?tab=1", Invoke(asm, "Home", "tab=1"));
+ Assert.AreEqual("/home?tab=1", Invoke(asm, "Home", "?tab=1"));
+ }
+
+ [TestMethod]
+ public void Root_page_generates_Root()
+ {
+ var (_, asm) = Run(("Index.razor", """
+ @page "/"
+ """));
+
+ Assert.AreEqual("/", Invoke(asm, "Root", Type.Missing));
+ }
+
+ [TestMethod]
+ public void Group_routes_add_no_segments_and_dynamic_paths_are_skipped()
+ {
+ var (_, asm) = Run(("Routes.razor", """
+
+
+
+
+
+
+
+
+ """));
+
+ Assert.AreEqual("/inside", Invoke(asm, "Inside", Type.Missing));
+ // Neither the dynamic route nor its child may generate anything.
+ Assert.IsFalse(Methods(asm).Any(m => m.Name.Contains("Unknowable", StringComparison.OrdinalIgnoreCase)));
+ }
+
+ [TestMethod]
+ public void Duplicate_templates_across_files_generate_once_preferring_the_named_one()
+ {
+ var (_, asm) = Run(
+ ("Page.razor", """
+ @page "/users/{id:int}"
+ """),
+ ("Routes.razor", """
+
+
+
+ """));
+
+ var methods = Methods(asm).Where(m => m.Name is "User" or "UsersById").ToArray();
+ Assert.AreEqual(1, methods.Length);
+ Assert.AreEqual("User", methods[0].Name);
+ }
+
+ [TestMethod]
+ public void Redirect_routes_and_wildcard_templates_are_not_generated()
+ {
+ var (_, asm) = Run(("Routes.razor", """
+
+
+
+
+
+ """));
+
+ var names = Methods(asm).Select(m => m.Name).ToArray();
+ CollectionAssert.Contains(names, "Home");
+ Assert.IsFalse(names.Contains("Root"));
+ Assert.IsFalse(names.Any(n => n.Contains("Files")));
+ }
+
+ [TestMethod]
+ public void Duplicate_template_with_conflicting_names_reports_a_diagnostic()
+ {
+ var (_, asm, diagnostics) = RunWithDiagnostics(("Routes.razor", """
+
+
+
+
+ """));
+
+ var diagnostic = diagnostics.Single(d => d.Id == "BRT001");
+ StringAssert.Contains(diagnostic.GetMessage(), "first");
+ StringAssert.Contains(diagnostic.GetMessage(), "second");
+ // The first declaration still wins deterministically.
+ Assert.AreEqual("/users/7", Invoke(asm, "First", 7, Type.Missing));
+ Assert.AreEqual("first", NameConstant(asm, "First"));
+ }
+
+ [TestMethod]
+ public void A_named_route_owns_its_method_name_over_an_unnamed_lookalike()
+ {
+ var (_, asm) = Run(("Routes.razor", """
+
+
+
+
+ """));
+
+ // The explicit name wins Counter(...); the unnamed literal route gets the suffix.
+ Assert.AreEqual("/counter/5", Invoke(asm, "Counter", 5, Type.Missing));
+ Assert.AreEqual("/counter", Invoke(asm, "Counter2", Type.Missing));
+ }
+
+ [TestMethod]
+ public void RouteAttribute_directives_are_discovered()
+ {
+ var (_, asm) = Run(("Legacy.razor", """
+ @attribute [Route("/legacy/{id:guid}")]
+ """));
+
+ var id = Guid.NewGuid();
+ Assert.AreEqual($"/legacy/{id}", Invoke(asm, "LegacyById", id, Type.Missing));
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Generators.Tests/GeneratorTestHarness.cs b/src/Brouter/Tests/Bit.Brouter.Generators.Tests/GeneratorTestHarness.cs
new file mode 100644
index 0000000000..c69020c39d
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Generators.Tests/GeneratorTestHarness.cs
@@ -0,0 +1,114 @@
+using System.Reflection;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+
+namespace Bit.Brouter.Generators.Tests;
+
+///
+/// Drives end-to-end: feeds it .razor files as AdditionalTexts,
+/// captures the generated source, compiles it in-memory, and loads the assembly so tests can invoke
+/// the generated URL builders and assert on real output instead of source-text snapshots.
+///
+internal static class GeneratorTestHarness
+{
+ public static (string GeneratedSource, Assembly Assembly) Run(params (string Path, string Content)[] razorFiles)
+ {
+ var (generated, assembly, _) = RunWithDiagnostics(razorFiles);
+ return (generated, assembly);
+ }
+
+ public static (string GeneratedSource, Assembly Assembly, System.Collections.Immutable.ImmutableArray Diagnostics) RunWithDiagnostics(
+ params (string Path, string Content)[] razorFiles)
+ {
+ var generator = new BrouterRoutesGenerator().AsSourceGenerator();
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(
+ generators: [generator],
+ additionalTexts: razorFiles.Select(f => (AdditionalText)new TestAdditionalText(f.Path, f.Content)),
+ optionsProvider: new TestOptionsProvider("TestApp"));
+
+ var compilation = CSharpCompilation.Create(
+ "TestApp",
+ syntaxTrees: [],
+ references: RuntimeReferences(),
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _);
+
+ var runResult = driver.GetRunResult();
+ var generated = runResult.Results.Single().GeneratedSources.SingleOrDefault().SourceText?.ToString()
+ ?? string.Empty;
+
+ using var ms = new MemoryStream();
+ var emit = outputCompilation.Emit(ms);
+ if (emit.Success is false)
+ {
+ var errors = string.Join(Environment.NewLine,
+ emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));
+ throw new InvalidOperationException($"Generated code failed to compile:{Environment.NewLine}{errors}{Environment.NewLine}--- generated source ---{Environment.NewLine}{generated}");
+ }
+
+ return (generated, Assembly.Load(ms.ToArray()), runResult.Results.Single().Diagnostics);
+ }
+
+ /// Invokes a generated BrouterRoutes method; omitted optionals via Type.Missing.
+ public static string Invoke(Assembly assembly, string method, params object?[] args)
+ {
+ var type = assembly.GetType("TestApp.BrouterRoutes")
+ ?? throw new InvalidOperationException("TestApp.BrouterRoutes was not generated.");
+
+ return (string)type.InvokeMember(
+ method,
+ BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Static | BindingFlags.OptionalParamBinding,
+ binder: null, target: null, args: args,
+ culture: System.Globalization.CultureInfo.InvariantCulture)!;
+ }
+
+ /// Reads a constant off the generated Names class.
+ public static string? NameConstant(Assembly assembly, string constant)
+ {
+ var type = assembly.GetType("TestApp.BrouterRoutes")?.GetNestedType("Names");
+ return (string?)type?.GetField(constant)?.GetValue(null);
+ }
+
+ public static MethodInfo[] Methods(Assembly assembly) =>
+ assembly.GetType("TestApp.BrouterRoutes")!
+ .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly);
+
+ private static IEnumerable RuntimeReferences()
+ {
+ var tpa = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!;
+ return tpa.Split(Path.PathSeparator)
+ .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
+ .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p));
+ }
+
+ private sealed class TestAdditionalText(string path, string content) : AdditionalText
+ {
+ public override string Path => path;
+ public override SourceText GetText(CancellationToken cancellationToken = default) =>
+ SourceText.From(content);
+ }
+
+ private sealed class TestOptionsProvider(string rootNamespace) : AnalyzerConfigOptionsProvider
+ {
+ public override AnalyzerConfigOptions GlobalOptions { get; } = new TestOptions(rootNamespace);
+ public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions;
+ public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions;
+
+ private sealed class TestOptions(string rootNamespace) : AnalyzerConfigOptions
+ {
+ public override bool TryGetValue(string key, out string value)
+ {
+ if (key == "build_property.RootNamespace")
+ {
+ value = rootNamespace;
+ return true;
+ }
+ value = string.Empty;
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/AmbiguousRouteTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/AmbiguousRouteTests.cs
new file mode 100644
index 0000000000..e87131482c
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/AmbiguousRouteTests.cs
@@ -0,0 +1,123 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+///
+/// Covers the registration-time ambiguity check in Brouter.RegisterRoute: two routes whose
+/// templates match exactly the same URLs (so winner selection could only tie-break by registration
+/// order) must be rejected, mirroring the built-in router's AmbiguousMatchException.
+///
+[TestClass]
+public class AmbiguousRouteTests : BunitTestContext
+{
+ private IRenderedComponent RenderPair(string pathA, string pathB)
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/__test__");
+
+ return RenderComponent(p => p
+ .Add(h => h.PathA, pathA)
+ .Add(h => h.PathB, pathB));
+ }
+
+ [TestMethod]
+ public void Exact_duplicate_template_throws()
+ {
+ var ex = Assert.ThrowsExactly(() => RenderPair("/users", "/users"));
+
+ StringAssert.Contains(ex.Message, "ambiguous");
+ }
+
+ [TestMethod]
+ public void Templates_differing_only_by_parameter_name_throw()
+ {
+ // "/users/{id}" and "/users/{userId}" match exactly the same URLs.
+ Assert.ThrowsExactly(() => RenderPair("/users/{id}", "/users/{userId}"));
+ }
+
+ [TestMethod]
+ public void Templates_differing_only_by_casing_throw_under_case_insensitive_matching()
+ {
+ // Options.CaseSensitive defaults to false, so "/Users" and "/users" match the same URLs.
+ Assert.ThrowsExactly(() => RenderPair("/Users", "/users"));
+ }
+
+ [TestMethod]
+ public void Templates_differing_only_by_casing_are_allowed_under_case_sensitive_matching()
+ {
+ Services.Configure(o => o.CaseSensitive = true);
+
+ RenderPair("/Users", "/users");
+ }
+
+ [TestMethod]
+ public void Literal_and_parameter_catch_all_forms_throw()
+ {
+ // "**" and "{**path}" both match zero-or-more remaining segments identically.
+ Assert.ThrowsExactly(() => RenderPair("/files/**", "/files/{**path}"));
+ }
+
+ [TestMethod]
+ public void Same_parameter_with_different_constraints_is_allowed()
+ {
+ // Different constraint sets match different URL sets (and score different specificity),
+ // so the pair is resolvable without falling back to registration order.
+ RenderPair("/users/{id}", "/users/{id:int}");
+ }
+
+ [TestMethod]
+ public void Overlapping_but_distinct_literals_are_allowed()
+ {
+ RenderPair("/users", "/users/list");
+ }
+
+ [TestMethod]
+ public void Parent_and_index_child_share_a_template_without_throwing()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/docs");
+
+ var cut = RenderComponent();
+
+ // The documented depth/index tiebreak resolves this pair: the index child wins.
+ cut.WaitForAssertion(() => Assert.AreEqual("index", cut.Find("[data-testid=index]").TextContent));
+ }
+
+ [TestMethod]
+ public void Hand_declared_route_may_shadow_a_discovered_page_with_the_same_template()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/discovered/7");
+
+ // DiscoveryOverrideHost hand-declares "/discovered/{id:int}", the exact template of the
+ // attribute-discovered DiscoveredPage. Registration must accept the pair (documented
+ // override pattern) and the hand-declared route must win the order tie.
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() => Assert.AreEqual("override", cut.Find("[data-testid=override]").TextContent));
+ }
+
+ [TestMethod]
+ public void Disposing_a_route_frees_its_template_for_re_registration()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/dup");
+
+ var cut = RenderComponent(p => p.Add(h => h.Show, "a"));
+ cut.WaitForAssertion(() => Assert.AreEqual("a", cut.Find("[data-testid=a]").TextContent));
+
+ // Dispose the first route in its own render pass, then mount a second route with the
+ // same template: the freed template must be accepted again (no ambiguity exception).
+ cut.SetParametersAndRender(p => p.Add(h => h.Show, "none"));
+ cut.SetParametersAndRender(p => p.Add(h => h.Show, "b"));
+
+ // Matching runs per navigation, not on registration, so navigate again to see route b win.
+ nav.NavigateTo("http://localhost/elsewhere");
+ nav.NavigateTo("http://localhost/dup");
+
+ cut.WaitForAssertion(() => Assert.AreEqual("b", cut.Find("[data-testid=b]").TextContent));
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/AmbiguousRoutesHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/AmbiguousRoutesHost.razor
new file mode 100644
index 0000000000..88bf515092
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/AmbiguousRoutesHost.razor
@@ -0,0 +1,17 @@
+@* Hosts two sibling routes with caller-supplied templates, used by AmbiguousRouteTests
+ to verify that registering an ambiguous (exact-duplicate) template throws. *@
+@using Bit.Brouter
+
+
+
+
a
+
+
+
b
+
+
+
+@code {
+ [Parameter, EditorRequired] public string PathA { get; set; } = default!;
+ [Parameter, EditorRequired] public string PathB { get; set; } = default!;
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/BrouterLinkTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/BrouterLinkTests.cs
index 54b959ee9b..81ceda028a 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/BrouterLinkTests.cs
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/BrouterLinkTests.cs
@@ -60,6 +60,42 @@ public void Prefix_match_does_not_activate_on_partial_segment()
});
}
+ [TestMethod]
+ public void Prefix_match_on_root_href_does_not_activate_on_other_pages()
+ {
+ // The classic NavLink footgun: a "home" link (Href="/") under the default Prefix match
+ // must NOT be active on every page just because every path starts with '/'. The root is
+ // matched exactly even under Prefix (mirrors React Router's NavLink).
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/users");
+
+ var cut = RenderComponent(p => p.Add(x => x.Href, "/"));
+
+ cut.WaitForAssertion(() =>
+ {
+ var anchor = cut.Find("[data-testid=link]");
+ var cls = anchor.GetAttribute("class") ?? "";
+ Assert.IsFalse(cls.Contains("active"), $"expected not active, got class='{cls}'");
+ Assert.IsNull(anchor.GetAttribute("aria-current"));
+ });
+ }
+
+ [TestMethod]
+ public void Prefix_match_on_root_href_activates_at_root()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/");
+
+ var cut = RenderComponent(p => p.Add(x => x.Href, "/"));
+
+ cut.WaitForAssertion(() =>
+ {
+ var anchor = cut.Find("[data-testid=link]");
+ StringAssert.Contains(anchor.GetAttribute("class") ?? "", "active");
+ Assert.AreEqual("page", anchor.GetAttribute("aria-current"));
+ });
+ }
+
[TestMethod]
public void All_match_only_activates_on_exact_equality()
{
@@ -181,9 +217,28 @@ public void Replace_does_not_navigate_on_modified_or_non_primary_clicks()
StringAssert.EndsWith(nav.Uri, "/start");
}
+ [TestMethod]
+ public void Multiple_Replace_links_share_a_single_module_import()
+ {
+ SetupReplaceJsModule();
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/start");
+
+ var cut = RenderComponent();
+
+ // Wait for all three links to finish wiring before counting imports, so a link that
+ // hasn't imported yet can't make the assertion pass vacuously.
+ var jsInvocations = Context!.JSInterop.Invocations;
+ cut.WaitForState(() => jsInvocations.Count(i => i.Identifier == "wireConditionalPreventDefault") == 3);
+
+ Assert.AreEqual(1, jsInvocations.Count(i => i.Identifier == "import"),
+ "all Replace links should reuse the scope-shared bit-brouter.js module instead of importing per link");
+ }
+
private void SetupReplaceJsModule()
{
- var module = Context!.JSInterop.SetupModule("./_content/Bit.Brouter/BitBrouter.js");
+ var module = Context!.JSInterop.SetupModule("./_content/Bit.Brouter/bit-brouter.js");
// BrouterLink.OnAfterRenderAsync calls module.InvokeAsync(
// "wireConditionalPreventDefault", _anchor) and stores the returned handle.
// bunit only allows IJSObjectReference results to be produced via SetupModule, so we
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/BrouterTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/BrouterTests.cs
index a272d6464f..9cd4e8c302 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/BrouterTests.cs
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/BrouterTests.cs
@@ -59,4 +59,26 @@ public void Trailing_slash_is_ignored_by_default()
cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=u]")));
}
+
+ [TestMethod]
+ public void Trailing_slash_fills_the_empty_optional_final_segment_under_strict_matching()
+ {
+ // With IgnoreTrailingSlash = false, "/users/" is distinct from "/users". The trailing
+ // slash legitimately stands in for the empty value of the unfilled optional final
+ // segment of "/users/{id?}", so it must still match (with the optional value absent).
+ Services.Configure(o => o.IgnoreTrailingSlash = false);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/users/");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() => Assert.AreEqual("(none)", cut.Find("[data-testid=out]").TextContent));
+
+ // A trailing slash after a fully-satisfied template is a real extra slash and must NOT
+ // match under strict trailing-slash handling.
+ nav.NavigateTo("http://localhost/users/42/");
+
+ cut.WaitForAssertion(() => Assert.AreEqual(0, cut.FindAll("[data-testid=out]").Count));
+ }
}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/CacheHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/CacheHost.razor
new file mode 100644
index 0000000000..88d6a28029
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/CacheHost.razor
@@ -0,0 +1,44 @@
+@* Host for LoaderCacheTests: a cached route whose loader counts runs and returns an incrementing
+ payload, an uncached route for preload tests, a plain /other route to navigate away to, and a
+ Render-mode preloading link. *@
+
+@using Bit.Brouter
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ [CascadingParameter] public BrouterRouteData? Data { get; set; }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ChildDataView.razor b/src/Brouter/Tests/Bit.Brouter.Tests/ChildDataView.razor
new file mode 100644
index 0000000000..f7aaea438b
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ChildDataView.razor
@@ -0,0 +1,7 @@
+@* Renders the cascaded loader data so RevalidateTests can observe data refreshes in the DOM. *@
+
+
@(Data?.GetOrDefault())
+
+@code {
+ [CascadingParameter] public BrouterRouteData? Data { get; set; }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ConstraintHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/ConstraintHost.razor
new file mode 100644
index 0000000000..5a788c1bb6
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ConstraintHost.razor
@@ -0,0 +1,8 @@
+@* Route using a custom "slug" constraint registered via BrouterOptions.Constraints. *@
+@using Bit.Brouter
+
+
+
+
@ctx["name"]
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ConstraintIntegrationTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/ConstraintIntegrationTests.cs
new file mode 100644
index 0000000000..49f5809692
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ConstraintIntegrationTests.cs
@@ -0,0 +1,45 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class ConstraintIntegrationTests : BunitTestContext
+{
+ private void RegisterSlug() =>
+ Services.Configure(o => o.Constraints.Register("slug",
+ new BrouterTypeRouteConstraint((string s, out string r) =>
+ {
+ r = s;
+ return s.Length >= 3 && s.All(c => char.IsLetterOrDigit(c) || c == '-');
+ })));
+
+ [TestMethod]
+ public void Container_scoped_constraint_flows_from_options_into_route_matching()
+ {
+ RegisterSlug();
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/posts/hello-world");
+
+ var cut = RenderComponent();
+
+ // The route matched only because Broute resolved "slug" against BrouterOptions.Constraints.
+ cut.WaitForAssertion(() => Assert.AreEqual("hello-world", cut.Find("[data-testid=post]").TextContent));
+ }
+
+ [TestMethod]
+ public void Container_scoped_constraint_rejects_a_non_matching_value()
+ {
+ RegisterSlug();
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/posts/ab"); // too short: fails the slug constraint
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() => Assert.AreEqual(0, cut.FindAll("[data-testid=post]").Count));
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ConstraintRegistryTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/ConstraintRegistryTests.cs
new file mode 100644
index 0000000000..833986b0a8
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ConstraintRegistryTests.cs
@@ -0,0 +1,89 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class ConstraintRegistryTests
+{
+ private static BrouterRouteConstraint Slug() =>
+ new BrouterTypeRouteConstraint((string s, out string r) =>
+ {
+ r = s;
+ return s.Length >= 3 && s.All(c => char.IsLetterOrDigit(c) || c == '-');
+ });
+
+ [TestMethod]
+ public void Custom_constraint_from_registry_is_resolved_during_parsing()
+ {
+ var registry = new BrouterConstraintRegistry();
+ registry.Register("slug", Slug());
+
+ var result = BrouterTemplateParser.ParseTemplate("/posts/{name:slug}", registry);
+
+ Assert.AreEqual(1, result.TemplateSegments[1].Constraints.Length);
+ Assert.AreEqual("slug", result.TemplateSegments[1].Constraints[0].Name);
+ }
+
+ [TestMethod]
+ public void Built_in_constraints_are_available_without_registration()
+ {
+ var registry = new BrouterConstraintRegistry();
+
+ var result = BrouterTemplateParser.ParseTemplate("/users/{id:int}", registry);
+
+ Assert.AreEqual(1, result.TemplateSegments[1].Constraints.Length);
+ }
+
+ [TestMethod]
+ public void Registering_a_built_in_name_throws()
+ {
+ var registry = new BrouterConstraintRegistry();
+
+ Assert.ThrowsExactly(() => registry.Register("int", Slug()));
+ }
+
+ [TestMethod]
+ public void Registering_a_duplicate_custom_name_throws()
+ {
+ var registry = new BrouterConstraintRegistry();
+ registry.Register("slug", Slug());
+
+ Assert.ThrowsExactly(() => registry.Register("slug", Slug()));
+ }
+
+ [TestMethod]
+ public void Custom_constraints_are_isolated_between_registries()
+ {
+ var withSlug = new BrouterConstraintRegistry();
+ withSlug.Register("slug", Slug());
+ var withoutSlug = new BrouterConstraintRegistry();
+
+ // The registry that has it resolves it...
+ _ = BrouterTemplateParser.ParseTemplate("/posts/{name:slug}", withSlug);
+
+ // ...a sibling container that never registered it does not (no process-wide leakage).
+ Assert.ThrowsExactly(
+ () => BrouterTemplateParser.ParseTemplate("/posts/{name:slug}", withoutSlug));
+ }
+
+ [TestMethod]
+ public void Unregister_removes_a_custom_constraint()
+ {
+ var registry = new BrouterConstraintRegistry();
+ registry.Register("slug", Slug());
+
+ Assert.IsTrue(registry.Unregister("slug"));
+
+ Assert.ThrowsExactly(
+ () => BrouterTemplateParser.ParseTemplate("/posts/{name:slug}", registry));
+ }
+
+ [TestMethod]
+ public void Unknown_constraint_throws()
+ {
+ var registry = new BrouterConstraintRegistry();
+
+ Assert.ThrowsExactly(
+ () => BrouterTemplateParser.ParseTemplate("/posts/{name:nope}", registry));
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/DeferredDataTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/DeferredDataTests.cs
new file mode 100644
index 0000000000..8b88107c38
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/DeferredDataTests.cs
@@ -0,0 +1,63 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class DeferredDataTests : BunitTestContext
+{
+ [TestMethod]
+ public void Route_reveals_immediately_while_deferred_data_is_pending()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/post");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ // Critical content is on screen although the deferred task hasn't resolved.
+ Assert.IsNotNull(cut.Find("[data-testid=post]"));
+ Assert.IsNotNull(cut.Find("[data-testid=comments-pending]"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=comments]").Count);
+ });
+ }
+
+ [TestMethod]
+ public void Deferred_data_streams_in_when_the_task_resolves()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/post");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=comments-pending]"));
+
+ cut.Instance.SlowGate.SetResult("42 comments");
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual("42 comments", cut.Find("[data-testid=comments]").TextContent);
+ Assert.AreEqual(0, cut.FindAll("[data-testid=comments-pending]").Count);
+ });
+ }
+
+ [TestMethod]
+ public void Deferred_failure_renders_the_error_fragment()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/post");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=comments-pending]"));
+
+ cut.Instance.SlowGate.SetException(new InvalidOperationException("comments api down"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsTrue(cut.Find("[data-testid=comments-error]").TextContent.Contains("comments api down"));
+ Assert.IsNotNull(cut.Find("[data-testid=post]")); // the page itself is unaffected
+ });
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/DeferredHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/DeferredHost.razor
new file mode 100644
index 0000000000..d638427db2
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/DeferredHost.razor
@@ -0,0 +1,30 @@
+@* Host for DeferredDataTests: the loader returns immediately with a deferred (unawaited) task
+ inside its result; the page renders BrouterAwait over it. Tests gate the task via SlowGate. *@
+
+@using Bit.Brouter
+
+
+
+
+
critical content
+
+
loading comments...
+
@comments
+
@ex.Message
+
+
+
+
+
+@code {
+ public TaskCompletionSource SlowGate { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Task? DeferredComments { get; private set; }
+
+ private ValueTask LoadPost(BrouterNavigationContext ctx)
+ {
+ // Critical part resolves immediately; the slow part is intentionally NOT awaited.
+ DeferredComments = SlowGate.Task;
+ return ValueTask.FromResult("post");
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveredPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveredPage.razor
new file mode 100644
index 0000000000..961acf3fe4
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveredPage.razor
@@ -0,0 +1,12 @@
+@page "/discovered/{id:int}"
+@using Microsoft.AspNetCore.Components
+
+
id:@Id q:@Q
+
+@code {
+ // Conventional Blazor binding: route parameter -> [Parameter] property by name.
+ [Parameter] public int Id { get; set; }
+
+ // Conventional Blazor binding: query value -> [SupplyParameterFromQuery] property.
+ [Parameter, SupplyParameterFromQuery] public string? Q { get; set; }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveryAndPersistenceTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveryAndPersistenceTests.cs
new file mode 100644
index 0000000000..fe4c267c4a
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveryAndPersistenceTests.cs
@@ -0,0 +1,124 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class DiscoveryAndPersistenceTests : BunitTestContext
+{
+ [TestMethod]
+ public void Attribute_routed_page_is_discovered_via_AppAssembly()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/discovered/42");
+
+ var cut = RenderComponent();
+
+ // The @page "/discovered/{id:int}" component was found by scanning the assembly - it was never
+ // hand-declared in DiscoveryHost - and matched the URL.
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=discovered]")));
+ }
+
+ [TestMethod]
+ public void Discovered_route_binds_route_and_query_parameters_conventionally()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/discovered/42?q=hello");
+
+ var cut = RenderComponent();
+
+ // Id bound from the {id:int} route parameter, Q bound from the ?q= query - both by name, with
+ // no [BrouterParameter] annotations, exactly like a plain Blazor @page.
+ cut.WaitForAssertion(() =>
+ Assert.AreEqual("id:42 q:hello", cut.Find("[data-testid=discovered]").TextContent));
+ }
+
+ [TestMethod]
+ public void Hand_declared_routes_still_match_alongside_discovered_ones()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() => Assert.AreEqual("home", cut.Find("[data-testid=home]").TextContent));
+ }
+
+ [TestMethod]
+ public void Prerender_state_round_trips_loader_value_with_its_concrete_type()
+ {
+ var dto = new SampleDto { Name = "saleh", Count = 3 };
+
+ var captured = BroutePrerenderState.Capture(dto);
+ var restored = BroutePrerenderState.TryRestore(captured, out var value);
+
+ Assert.IsTrue(restored);
+ var typed = value as SampleDto;
+ Assert.IsNotNull(typed);
+ Assert.AreEqual("saleh", typed!.Name);
+ Assert.AreEqual(3, typed.Count);
+ }
+
+ [TestMethod]
+ public void Prerender_state_round_trips_a_null_loader_result()
+ {
+ var captured = BroutePrerenderState.Capture(null);
+
+ // A persisted null is a real decision (loader ran and returned null): restoration must succeed
+ // with a null value so the interactive pass skips re-running the loader.
+ Assert.IsTrue(BroutePrerenderState.TryRestore(captured, out var value));
+ Assert.IsNull(value);
+ }
+
+ [TestMethod]
+ public void Prerender_state_round_trips_with_a_source_generated_resolver()
+ {
+ var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
+ {
+ TypeInfoResolver = PersistenceTestJsonContext.Default,
+ };
+
+ var dto = new SampleDto { Name = "aot", Count = 7 };
+ var captured = BroutePrerenderState.Capture(dto, options);
+
+ Assert.IsNotNull(captured);
+ Assert.IsTrue(BroutePrerenderState.TryRestore(captured, out var value, options));
+ var typed = value as SampleDto;
+ Assert.IsNotNull(typed);
+ Assert.AreEqual("aot", typed!.Name);
+ Assert.AreEqual(7, typed.Count);
+ }
+
+ [TestMethod]
+ public void Capture_of_a_type_the_resolver_does_not_cover_returns_null_instead_of_throwing()
+ {
+ var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
+ {
+ TypeInfoResolver = PersistenceTestJsonContext.Default,
+ };
+
+ // Uri isn't registered on the context: capture must degrade to "don't persist" (null),
+ // never break prerender.
+ var captured = BroutePrerenderState.Capture(new UncoveredDto(), options);
+
+ Assert.IsNull(captured);
+ }
+
+ public sealed class UncoveredDto
+ {
+ public int X { get; set; }
+ }
+
+ [TestMethod]
+ public void Prerender_key_is_stable_for_the_same_url_and_chain_position()
+ {
+ var a = BroutePrerenderState.MakeKey("/users/42", "?tab=1", 2);
+ var b = BroutePrerenderState.MakeKey("/users/42", "?tab=1", 2);
+ var different = BroutePrerenderState.MakeKey("/users/42", "?tab=1", 3);
+
+ Assert.AreEqual(a, b);
+ Assert.AreNotEqual(a, different);
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveryHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveryHost.razor
new file mode 100644
index 0000000000..5bb94a7554
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveryHost.razor
@@ -0,0 +1,9 @@
+@using Bit.Brouter
+
+@* Scans this test assembly for [Route]/@page components (e.g. DiscoveredPage) and matches them
+ alongside the hand-declared /home route. *@
+
+
+
home
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveryOverrideHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveryOverrideHost.razor
new file mode 100644
index 0000000000..18a84571dc
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/DiscoveryOverrideHost.razor
@@ -0,0 +1,10 @@
+@* Hand-declares a route with the exact template of the attribute-discovered DiscoveredPage
+ (@page "/discovered/{id:int}"). This is the documented override pattern: the hand-declared
+ route registers first, wins the order tie, and must NOT be rejected as ambiguous. *@
+@using Bit.Brouter
+
+
+
+
override
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ErrorContentHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/ErrorContentHost.razor
new file mode 100644
index 0000000000..ec1e5f9c4f
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ErrorContentHost.razor
@@ -0,0 +1,83 @@
+@* Host for ErrorContentTests: routes with failing loaders at three boundary depths - a leaf route
+ with its own ErrorContent, a nested child bubbling to its parent's ErrorContent, and a route
+ with no chain boundary that falls back to the router-level ErrorContent. *@
+
+@using Bit.Brouter
+@implements IDisposable
+@inject IBrouter Brouter
+
+
+
+
+
ok
+
+
+
+
leaf ok
+
+
@err.Exception.Message
+
+
+
+
+
+
+
+
+
+
+
+
+
child ok
+
+
+
+
@err.Exception.Message
+
+
+
+
+
root ok
+
+
+
+
@err.Exception.Message
+
+
+
+@code {
+ // Toggled by tests: while true, the leaf loader throws; flipping to false lets a retry succeed.
+ public bool LeafShouldFail { get; set; } = true;
+
+ public int ErrorHookCount { get; private set; }
+ public Exception? LastError { get; private set; }
+
+ private Func? _onError;
+
+ protected override void OnInitialized()
+ {
+ _onError = (ctx, ex) =>
+ {
+ ErrorHookCount++;
+ LastError = ex;
+ return ValueTask.CompletedTask;
+ };
+ Brouter.OnError += _onError;
+ }
+
+ private ValueTask LeafLoader(BrouterNavigationContext ctx)
+ => LeafShouldFail
+ ? throw new InvalidOperationException("leaf loader failed")
+ : ValueTask.FromResult("leaf data");
+
+ private ValueTask ChildLoader(BrouterNavigationContext ctx)
+ => throw new InvalidOperationException("child loader failed");
+
+ private ValueTask RootFailLoader(BrouterNavigationContext ctx)
+ => throw new InvalidOperationException("root loader failed");
+
+ public void Dispose()
+ {
+ if (_onError is not null) Brouter.OnError -= _onError;
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ErrorContentTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/ErrorContentTests.cs
new file mode 100644
index 0000000000..7b018d22f5
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ErrorContentTests.cs
@@ -0,0 +1,129 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class ErrorContentTests : BunitTestContext
+{
+ [TestMethod]
+ public void Loader_failure_renders_the_routes_own_ErrorContent_and_still_fires_OnError()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/fail-leaf");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var boundary = cut.Find("[data-testid=leaf-boundary]");
+ Assert.IsTrue(boundary.TextContent.Contains("leaf loader failed"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=leaf-content]").Count);
+ // Boundaries are UI; observability still goes through the OnError hook.
+ Assert.AreEqual(1, cut.Instance.ErrorHookCount);
+ Assert.IsInstanceOfType(cut.Instance.LastError);
+ // The URL committed - the error happened in the commit phase, after the address bar moved.
+ Assert.IsTrue(nav.Uri.EndsWith("/fail-leaf", StringComparison.Ordinal));
+ });
+ }
+
+ [TestMethod]
+ public void Child_failure_without_own_boundary_bubbles_to_the_parents_ErrorContent()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/parent/child");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var boundary = cut.Find("[data-testid=parent-boundary]");
+ Assert.IsTrue(boundary.TextContent.Contains("child loader failed"));
+ // The boundary replaces the parent's content (layout included), mirroring
+ // React Router's ErrorBoundary semantics; the child never rendered.
+ Assert.AreEqual(0, cut.FindAll("[data-testid=parent-layout]").Count);
+ Assert.AreEqual(0, cut.FindAll("[data-testid=child-content]").Count);
+ });
+ }
+
+ [TestMethod]
+ public void Failure_with_no_route_boundary_falls_back_to_the_router_level_ErrorContent()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/fail-root");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var boundary = cut.Find("[data-testid=root-boundary]");
+ Assert.IsTrue(boundary.TextContent.Contains("root loader failed"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=root-content]").Count);
+ });
+ }
+
+ [TestMethod]
+ public void Retry_re_runs_the_navigation_and_replaces_the_error_ui_on_success()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/fail-leaf");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=leaf-boundary]"));
+
+ // Heal the loader, then retry from the boundary UI.
+ cut.Instance.LeafShouldFail = false;
+ cut.Find("[data-testid=retry]").Click();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=leaf-content]"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=leaf-boundary]").Count);
+ });
+ }
+
+ [TestMethod]
+ public void A_later_successful_navigation_clears_the_error_boundary()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/fail-root");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=root-boundary]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/ok"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=ok]"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=root-boundary]").Count);
+ });
+ }
+
+ [TestMethod]
+ public void Route_boundary_clears_when_the_same_route_later_matches_successfully()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/fail-leaf");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=leaf-boundary]"));
+
+ // Navigate away, heal the loader, come back: no stale error UI may survive.
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/ok"));
+ cut.WaitForAssertion(() => cut.Find("[data-testid=ok]"));
+
+ cut.Instance.LeafShouldFail = false;
+ cut.InvokeAsync(() => brouter.Navigate("/fail-leaf"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=leaf-content]"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=leaf-boundary]").Count);
+ });
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/GroupRouteHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/GroupRouteHost.razor
new file mode 100644
index 0000000000..0912cd98ba
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/GroupRouteHost.razor
@@ -0,0 +1,64 @@
+@* Host for GroupRouteTests: two sibling pathless groups (would be an ambiguity error if groups
+ registered as matchable), one carrying a guard + loader + layout for its children. *@
+
+@using Bit.Brouter
+
+
+
+
+
+
+
+
+
admin a
+
+
+
admin b
+
+
+
+
+
+
+
+
public
+
+
+
+
+ @* A group WITHOUT its own outlets under an outlet-hosting parent: the group must be invisible
+ to layout resolution too, so /shell/inside renders inside the parent's outlet. *@
+
+
+
+
+
+
+
+
+
inside
+
+
+
+
+
+
+
+@code {
+ public bool BlockAdmin { get; set; }
+ public int GuardRuns { get; private set; }
+ public int LoaderRuns { get; private set; }
+
+ private ValueTask AdminGuard(BrouterNavigationContext ctx)
+ {
+ GuardRuns++;
+ if (BlockAdmin) ctx.Redirect("/public");
+ return ValueTask.CompletedTask;
+ }
+
+ private ValueTask GroupLoader(BrouterNavigationContext ctx)
+ {
+ LoaderRuns++;
+ return ValueTask.FromResult("group data");
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/GroupRouteTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/GroupRouteTests.cs
new file mode 100644
index 0000000000..51386148b7
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/GroupRouteTests.cs
@@ -0,0 +1,97 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class GroupRouteTests : BunitTestContext
+{
+ [TestMethod]
+ public void Group_children_match_at_their_own_paths_and_render_inside_the_group_layout()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/admin-a");
+
+ // Two sibling pathless groups coexist without an ambiguity error, and the group adds no
+ // URL segment: /admin-a matches directly.
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var shell = cut.Find("[data-testid=admin-shell]");
+ Assert.IsNotNull(shell.QuerySelector("[data-testid=admin-a]"));
+ });
+ }
+
+ [TestMethod]
+ public void Group_guard_and_loader_run_for_child_navigations()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/admin-a");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=admin-a]"));
+
+ Assert.AreEqual(1, cut.Instance.GuardRuns);
+ Assert.AreEqual(1, cut.Instance.LoaderRuns);
+ }
+
+ [TestMethod]
+ public void Group_guard_redirect_protects_every_child()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/public");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=public]"));
+
+ cut.Instance.BlockAdmin = true;
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/admin-b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ // Redirected by the group's guard before the URL ever committed.
+ Assert.IsNotNull(cut.Find("[data-testid=public]"));
+ Assert.IsTrue(nav.Uri.EndsWith("/public", StringComparison.Ordinal));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=admin-b]").Count);
+ });
+ }
+
+ [TestMethod]
+ public void Group_is_invisible_to_outlet_resolution()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/shell/inside");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ // The child sits inside a pathless group with no outlets of its own; its content must
+ // pass through the group into the grandparent's outlet, not render at the declaration
+ // site.
+ var outlet = cut.Find("[data-testid=shell-outlet]");
+ Assert.IsNotNull(outlet.QuerySelector("[data-testid=inside-shell]"));
+ });
+ }
+
+ [TestMethod]
+ public void Group_does_not_shadow_sibling_routes()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/public");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=public]"));
+ // The admin group's guard/loader must not run for a route outside it.
+ Assert.AreEqual(0, cut.Instance.GuardRuns);
+ Assert.AreEqual(0, cut.Instance.LoaderRuns);
+ });
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/GuardAndLoaderTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/GuardAndLoaderTests.cs
index ed1bd99456..9081c1a8e5 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/GuardAndLoaderTests.cs
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/GuardAndLoaderTests.cs
@@ -28,4 +28,84 @@ public void Loader_value_is_exposed_via_RouteData()
var cut = RenderComponent();
cut.WaitForAssertion(() => Assert.AreEqual("loaded!", cut.Find("[data-testid=val]").TextContent));
}
+
+ [TestMethod]
+ public void Chain_loaders_run_sequentially_root_to_leaf_by_default()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/parent/child");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=done]")), TimeSpan.FromSeconds(3));
+
+ CollectionAssert.AreEqual(
+ new[] { "parent:start", "parent:end", "child:start" },
+ cut.Instance.Events);
+ }
+
+ [TestMethod]
+ public void ParallelLoaders_runs_chain_loaders_concurrently()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/parent/child");
+
+ var cut = RenderComponent();
+
+ // The parent's loader completes only after the child's has started, so rendering
+ // finishing at all proves the two loaders overlapped (sequential execution would
+ // time out inside the parent's loader and never match the route).
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=done]")), TimeSpan.FromSeconds(3));
+ }
+
+ [TestMethod]
+ public void Guard_cancel_prevents_navigation_before_the_url_commits()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=home]")));
+
+ // Navigate to a guarded route whose guard cancels. Because the guard now runs inside the
+ // RegisterLocationChangingHandler decision, the URL must never change to /cancel: the
+ // navigation is PREVENTED, not committed-then-reverted.
+ nav.NavigateTo("http://localhost/cancel");
+
+ cut.WaitForAssertion(() =>
+ {
+ StringAssert.EndsWith(nav.Uri, "/home");
+ var latest = nav.History.First();
+ StringAssert.EndsWith(latest.Uri, "cancel");
+ Assert.AreEqual(NavigationState.Prevented, latest.State);
+ });
+
+ // The guarded route never rendered, and we stayed on /home.
+ Assert.AreEqual(0, cut.FindAll("[data-testid=cancel]").Count);
+ Assert.IsNotNull(cut.Find("[data-testid=home]"));
+ }
+
+ [TestMethod]
+ public void Guard_redirect_prevents_the_original_navigation_and_lands_on_the_target()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=home]")));
+
+ // Navigate to a guarded route whose guard redirects to /login. The intermediate /redirect
+ // URL must be prevented (never committed), and we end up on /login.
+ nav.NavigateTo("http://localhost/redirect");
+
+ cut.WaitForAssertion(() =>
+ {
+ StringAssert.EndsWith(nav.Uri, "/login");
+ Assert.IsNotNull(cut.Find("[data-testid=login]"));
+ });
+
+ // The blocked /redirect entry was prevented, never succeeded.
+ var redirectEntry = nav.History.FirstOrDefault(h => h.Uri.EndsWith("redirect"));
+ Assert.IsNotNull(redirectEntry);
+ Assert.AreEqual(NavigationState.Prevented, redirectEntry!.State);
+ }
}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/GuardHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/GuardHost.razor
index 53f30290c0..337f2025f1 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/GuardHost.razor
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/GuardHost.razor
@@ -1,15 +1,15 @@
@using Bit.Brouter
-
+
should not render
-
-
+
+
login
-
-
+
+ 404
-
+
@code {
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/HistoryStateHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/HistoryStateHost.razor
new file mode 100644
index 0000000000..5c354cf69b
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/HistoryStateHost.razor
@@ -0,0 +1,39 @@
+@* Hosts a Brouter with two routes plus a state-carrying BrouterLink so HistoryStateTests can
+ exercise IBrouter.Navigate(historyState:) and BrouterLink.HistoryState against the real
+ navigation pipeline (including what guards/hooks observe on ctx.To). *@
+
+@using Bit.Brouter
+@implements IDisposable
+@inject IBrouter Brouter
+
+
+
+
a
+
+
+
b
+
+
+
+go b
+
+@code {
+ [Parameter] public string? LinkState { get; set; }
+ [Parameter] public bool LinkReplace { get; set; }
+
+ // History state observed on ctx.To by the OnNavigated hook for the most recent navigation.
+ // Distinguishes "hook not fired" (unset) from a genuinely null state via HookFired.
+ public string? LastHookState { get; private set; }
+ public bool HookFired { get; private set; }
+
+ protected override void OnInitialized() => Brouter.OnNavigated += OnNavigated;
+
+ private ValueTask OnNavigated(BrouterNavigationContext ctx)
+ {
+ HookFired = true;
+ LastHookState = ctx.To.HistoryState;
+ return ValueTask.CompletedTask;
+ }
+
+ public void Dispose() => Brouter.OnNavigated -= OnNavigated;
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/HistoryStateTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/HistoryStateTests.cs
new file mode 100644
index 0000000000..c9d5ec0626
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/HistoryStateTests.cs
@@ -0,0 +1,120 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class HistoryStateTests : BunitTestContext
+{
+ [TestMethod]
+ public void Navigate_without_state_exposes_null_HistoryState()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ Assert.IsNull(brouter.Location.HistoryState);
+ Assert.IsTrue(cut.Instance.HookFired);
+ Assert.IsNull(cut.Instance.LastHookState);
+ });
+ }
+
+ [TestMethod]
+ public void Navigate_with_state_exposes_it_on_Location_and_hooks()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b", historyState: "payload-42"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ Assert.AreEqual("payload-42", brouter.Location.HistoryState);
+ Assert.AreEqual("payload-42", cut.Instance.LastHookState);
+ });
+ }
+
+ [TestMethod]
+ public void Navigate_with_state_and_replace_replaces_the_entry_and_keeps_the_state()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b", replace: true, historyState: "replaced-state"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ Assert.AreEqual("replaced-state", brouter.Location.HistoryState);
+ });
+
+ // The FakeNavigationManager records the NavigationOptions it was invoked with; verify the
+ // replace flag survived the options-based NavigateTo overload used when state is present.
+ var last = nav.History.Last();
+ Assert.IsTrue(last.Options.ReplaceHistoryEntry);
+ Assert.AreEqual("replaced-state", last.Options.HistoryEntryState);
+ }
+
+ [TestMethod]
+ public void Link_click_with_HistoryState_attaches_the_state()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent(p => p.Add(x => x.LinkState, "from-link"));
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ // The state-carrying link intercepts clicks the same way Replace links do; the JS wiring
+ // happens in OnAfterRenderAsync against the (loose-mode) module mock. Click once wired.
+ var brouter = Services.GetRequiredService();
+ cut.WaitForAssertion(() =>
+ {
+ cut.Find("[data-testid=state-link]").Click();
+ Assert.AreEqual("from-link", brouter.Location.HistoryState);
+ });
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ Assert.AreEqual("from-link", cut.Instance.LastHookState);
+ });
+ }
+
+ [TestMethod]
+ public void NavigateToName_forwards_history_state()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent(p => p
+ .Add(x => x.Name, "user")
+ .Add(x => x.Path, "/users/{id:int}"));
+ var brouter = Services.GetRequiredService();
+
+ cut.InvokeAsync(() => brouter.NavigateToName(
+ "user",
+ new Dictionary { ["id"] = 42 },
+ historyState: "named-state"));
+
+ cut.WaitForAssertion(() => Assert.AreEqual("named-state", brouter.Location.HistoryState));
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveHost.razor
new file mode 100644
index 0000000000..f6256cf16b
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveHost.razor
@@ -0,0 +1,55 @@
+@* Host for KeepAliveTests: a keep-alive route and a plain control route (rendered inline), plus a
+ parent-outlet pair exercising keep-alive through BrouterOutlet. *@
+
+@using Bit.Brouter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
other
+
+
+
+
+
+
+
+
+
+
+
+
k2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveLifecycle.razor b/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveLifecycle.razor
new file mode 100644
index 0000000000..3dddc1c225
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveLifecycle.razor
@@ -0,0 +1,23 @@
+@* Consumes the keep-alive lifecycle context to record activate/deactivate transitions, for
+ asserting that a kept component is signalled when it is hidden and shown again. *@
+
+@using Bit.Brouter
+
+
+
+@code {
+ [CascadingParameter] public BrouterKeepAliveContext? KeepAlive { get; set; }
+
+ private bool IsActive => KeepAlive?.IsActive ?? true;
+ private int _deactivations;
+ private int _activations;
+ private bool _wasActive = true;
+
+ protected override void OnParametersSet()
+ {
+ var active = IsActive;
+ if (_wasActive && active is false) _deactivations++;
+ if (_wasActive is false && active) _activations++;
+ _wasActive = active;
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveParam.razor b/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveParam.razor
new file mode 100644
index 0000000000..f89f1c1ebc
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveParam.razor
@@ -0,0 +1,14 @@
+@* Shows the bound {id} route parameter alongside internal counter state, for asserting how
+ keep-alive behaves when the same route matches different parameter values. *@
+
+@using Bit.Brouter
+
+
id:@Id count:@_count
+
+
+@code {
+ [CascadingParameter(Name = "RouteParameters")] public BrouterRouteParameters? Params { get; set; }
+
+ private int Id => Params?.GetOrDefault("id") ?? 0;
+ private int _count;
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveTests.cs
new file mode 100644
index 0000000000..1e7b0e66ea
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/KeepAliveTests.cs
@@ -0,0 +1,317 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class KeepAliveTests : BunitTestContext
+{
+ private (IRenderedComponent Cut, IBrouter Brouter) RenderAt(string url)
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo(url);
+ var cut = RenderComponent();
+ return (cut, Services.GetRequiredService());
+ }
+
+ [TestMethod]
+ public async Task KeepAlive_route_preserves_component_state_across_navigations()
+ {
+ var (cut, brouter) = RenderAt("http://localhost/ka");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=stateful]"));
+
+ cut.Find("[data-testid=inc]").Click();
+ cut.Find("[data-testid=inc]").Click();
+ Assert.AreEqual("count:2", cut.Find("[data-testid=stateful]").TextContent);
+
+ await cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=other]"));
+ // Still mounted, but inside the hidden wrapper.
+ Assert.IsNotNull(cut.Find("div[hidden] [data-testid=stateful]"));
+ });
+
+ await cut.InvokeAsync(() => brouter.Navigate("/ka"));
+ cut.WaitForAssertion(() =>
+ {
+ // Visible again - and the component instance (with its state) survived.
+ Assert.AreEqual(0, cut.FindAll("div[hidden] [data-testid=stateful]").Count);
+ Assert.AreEqual("count:2", cut.Find("[data-testid=stateful]").TextContent);
+ });
+ }
+
+ [TestMethod]
+ public async Task Plain_route_recreates_its_component_on_return()
+ {
+ var (cut, brouter) = RenderAt("http://localhost/plain");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=stateful]"));
+
+ cut.Find("[data-testid=inc]").Click();
+ cut.Find("[data-testid=inc]").Click();
+ Assert.AreEqual("count:2", cut.Find("[data-testid=stateful]").TextContent);
+
+ await cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=other]"));
+ // Not keep-alive: unmounted entirely.
+ Assert.AreEqual(0, cut.FindAll("[data-testid=stateful]").Count);
+ });
+
+ await cut.InvokeAsync(() => brouter.Navigate("/plain"));
+ cut.WaitForAssertion(() => Assert.AreEqual("count:0", cut.Find("[data-testid=stateful]").TextContent));
+ }
+
+ [TestMethod]
+ public async Task KeepAlive_works_through_a_parent_outlet_for_sibling_switches()
+ {
+ var (cut, brouter) = RenderAt("http://localhost/parent/k1");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=stateful]"));
+
+ cut.Find("[data-testid=inc]").Click();
+ Assert.AreEqual("count:1", cut.Find("[data-testid=stateful]").TextContent);
+
+ await cut.InvokeAsync(() => brouter.Navigate("/parent/k2"));
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=k2]"));
+ // k1 is retained hidden inside the outlet's kept region.
+ Assert.IsNotNull(cut.Find("div[hidden] [data-testid=stateful]"));
+ });
+
+ await cut.InvokeAsync(() => brouter.Navigate("/parent/k1"));
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual("count:1", cut.Find("[data-testid=stateful]").TextContent);
+ Assert.AreEqual(0, cut.FindAll("div[hidden] [data-testid=stateful]").Count);
+ // k2 was transient: gone.
+ Assert.AreEqual(0, cut.FindAll("[data-testid=k2]").Count);
+ });
+ }
+
+ [TestMethod]
+ public async Task KeepAlive_context_signals_deactivate_and_reactivate_transitions()
+ {
+ var (cut, brouter) = RenderAt("http://localhost/kl");
+ cut.WaitForAssertion(() => StringAssert.Contains(cut.Find("[data-testid=lifecycle]").TextContent, "active:True"));
+
+ await cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() =>
+ {
+ // Kept mounted but hidden - and the component was told it is now inactive.
+ var el = cut.Find("div[hidden] [data-testid=lifecycle]");
+ StringAssert.Contains(el.TextContent, "active:False");
+ StringAssert.Contains(el.TextContent, "deactivations:1");
+ });
+
+ await cut.InvokeAsync(() => brouter.Navigate("/kl"));
+ cut.WaitForAssertion(() =>
+ {
+ var el = cut.Find("[data-testid=lifecycle]");
+ StringAssert.Contains(el.TextContent, "active:True");
+ // Same instance survived, so the transition counters accumulated rather than reset.
+ StringAssert.Contains(el.TextContent, "deactivations:1");
+ StringAssert.Contains(el.TextContent, "activations:1");
+ });
+ }
+
+ [TestMethod]
+ public async Task ClearKeepAlive_drops_retained_state_so_returning_recreates()
+ {
+ var (cut, brouter) = RenderAt("http://localhost/ka");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=stateful]"));
+
+ cut.Find("[data-testid=inc]").Click();
+ cut.Find("[data-testid=inc]").Click();
+ Assert.AreEqual("count:2", cut.Find("[data-testid=stateful]").TextContent);
+
+ await cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("div[hidden] [data-testid=stateful]")));
+
+ // Evict the retained page: the hidden instance is disposed and its state released.
+ await cut.InvokeAsync(() => brouter.ClearKeepAlive());
+ cut.WaitForAssertion(() => Assert.AreEqual(0, cut.FindAll("[data-testid=stateful]").Count));
+
+ // Returning now recreates it fresh (count back to 0), proving the state was really dropped.
+ await cut.InvokeAsync(() => brouter.Navigate("/ka"));
+ cut.WaitForAssertion(() => Assert.AreEqual("count:0", cut.Find("[data-testid=stateful]").TextContent));
+ }
+
+ [TestMethod]
+ public async Task KeepAlive_is_keyed_per_route_not_per_parameter_value()
+ {
+ // Documents the deliberate DEFAULT (KeepAliveMax unset => 1): a parameterized keep-alive
+ // route keeps ONE live instance, reused across parameter values - not a separate cached
+ // page per value. Set KeepAliveMax > 1 for per-parameter retention (tests below).
+ var (cut, brouter) = RenderAt("http://localhost/item/1");
+ cut.WaitForAssertion(() => StringAssert.Contains(cut.Find("[data-testid=kp]").TextContent, "id:1"));
+
+ cut.Find("[data-testid=kpinc]").Click();
+ StringAssert.Contains(cut.Find("[data-testid=kp]").TextContent, "count:1");
+
+ await cut.InvokeAsync(() => brouter.Navigate("/item/2"));
+ cut.WaitForAssertion(() =>
+ {
+ var t = cut.Find("[data-testid=kp]").TextContent;
+ StringAssert.Contains(t, "id:2"); // the same instance re-binds to the new parameter value
+ StringAssert.Contains(t, "count:1"); // and its state carries over - it was NOT reset or separately cached
+ });
+
+ // There is only ever one instance for the route: no hidden retained variant for id=1.
+ Assert.AreEqual(0, cut.FindAll("div[hidden] [data-testid=kp]").Count);
+ }
+
+ [TestMethod]
+ public async Task KeepAlive_state_is_lost_across_the_hosting_layout_unmount()
+ {
+ // Documents the lifetime boundary: keep-alive survives sibling switches under a layout, but
+ // not the layout's own unmount. Leaving /parent disposes the outlet-hosted k1, so returning
+ // rebuilds it fresh (count back to 0) rather than restoring the count:1 we left with.
+ var (cut, brouter) = RenderAt("http://localhost/parent/k1");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=stateful]"));
+
+ cut.Find("[data-testid=inc]").Click();
+ Assert.AreEqual("count:1", cut.Find("[data-testid=stateful]").TextContent);
+
+ await cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=other]")));
+
+ await cut.InvokeAsync(() => brouter.Navigate("/parent/k1"));
+ cut.WaitForAssertion(() => Assert.AreEqual("count:0", cut.Find("[data-testid=stateful]").TextContent));
+ }
+
+ // ---- Per-parameter keep-alive (KeepAliveMax > 1) ----
+
+ private const string VisibleKp = "div:not([hidden]) > [data-testid=kp]";
+ private const string HiddenKp = "div[hidden] > [data-testid=kp]";
+ private const string VisibleInc = "div:not([hidden]) > [data-testid=kpinc]";
+
+ [TestMethod]
+ public async Task KeepAliveMax_keeps_separate_state_per_parameter_value()
+ {
+ var (cut, brouter) = RenderAt("http://localhost/multi/1");
+ cut.WaitForAssertion(() => StringAssert.Contains(cut.Find(VisibleKp).TextContent, "id:1"));
+
+ cut.Find(VisibleInc).Click();
+ StringAssert.Contains(cut.Find(VisibleKp).TextContent, "count:1");
+
+ // Visit id=2: a SEPARATE instance mounts (fresh count), while id=1 is retained hidden with
+ // its state and parameter binding frozen.
+ await cut.InvokeAsync(() => brouter.Navigate("/multi/2"));
+ cut.WaitForAssertion(() =>
+ {
+ var visible = cut.Find(VisibleKp).TextContent;
+ StringAssert.Contains(visible, "id:2");
+ StringAssert.Contains(visible, "count:0");
+ var hidden = cut.Find(HiddenKp).TextContent;
+ StringAssert.Contains(hidden, "id:1");
+ StringAssert.Contains(hidden, "count:1");
+ });
+
+ cut.Find(VisibleInc).Click();
+ cut.Find(VisibleInc).Click();
+ StringAssert.Contains(cut.Find(VisibleKp).TextContent, "count:2");
+
+ // Return to id=1: its exact state resumes; id=2 is now the hidden one with count:2.
+ await cut.InvokeAsync(() => brouter.Navigate("/multi/1"));
+ cut.WaitForAssertion(() =>
+ {
+ var visible = cut.Find(VisibleKp).TextContent;
+ StringAssert.Contains(visible, "id:1");
+ StringAssert.Contains(visible, "count:1");
+ var hidden = cut.Find(HiddenKp).TextContent;
+ StringAssert.Contains(hidden, "id:2");
+ StringAssert.Contains(hidden, "count:2");
+ });
+ }
+
+ [TestMethod]
+ public async Task KeepAliveMax_evicts_the_least_recently_used_entry_beyond_the_budget()
+ {
+ var (cut, brouter) = RenderAt("http://localhost/multi/1");
+ cut.WaitForAssertion(() => StringAssert.Contains(cut.Find(VisibleKp).TextContent, "id:1"));
+ cut.Find(VisibleInc).Click();
+
+ await cut.InvokeAsync(() => brouter.Navigate("/multi/2"));
+ cut.WaitForAssertion(() => StringAssert.Contains(cut.Find(VisibleKp).TextContent, "id:2"));
+ cut.Find(VisibleInc).Click();
+
+ // Third distinct value with a budget of 2: id=1 (least recently used) is evicted/disposed.
+ await cut.InvokeAsync(() => brouter.Navigate("/multi/3"));
+ cut.WaitForAssertion(() =>
+ {
+ StringAssert.Contains(cut.Find(VisibleKp).TextContent, "id:3");
+ Assert.AreEqual(2, cut.FindAll("[data-testid=kp]").Count); // id:3 visible + id:2 hidden
+ StringAssert.Contains(cut.Find(HiddenKp).TextContent, "id:2");
+ });
+
+ // id=2 survived within the budget: its state resumes.
+ await cut.InvokeAsync(() => brouter.Navigate("/multi/2"));
+ cut.WaitForAssertion(() => StringAssert.Contains(cut.Find(VisibleKp).TextContent, "count:1"));
+
+ // id=1 was evicted: recreated fresh.
+ await cut.InvokeAsync(() => brouter.Navigate("/multi/1"));
+ cut.WaitForAssertion(() =>
+ {
+ var visible = cut.Find(VisibleKp).TextContent;
+ StringAssert.Contains(visible, "id:1");
+ StringAssert.Contains(visible, "count:0");
+ });
+ }
+
+ [TestMethod]
+ public async Task KeepAliveMax_keeps_per_parameter_state_through_a_parent_outlet()
+ {
+ var (cut, brouter) = RenderAt("http://localhost/mparent/mi/1");
+ cut.WaitForAssertion(() => StringAssert.Contains(cut.Find(VisibleKp).TextContent, "id:1"));
+ cut.Find(VisibleInc).Click();
+ StringAssert.Contains(cut.Find(VisibleKp).TextContent, "count:1");
+
+ await cut.InvokeAsync(() => brouter.Navigate("/mparent/mi/2"));
+ cut.WaitForAssertion(() =>
+ {
+ StringAssert.Contains(cut.Find(VisibleKp).TextContent, "id:2");
+ // id=1's subtree is retained hidden inside the outlet, parameters frozen.
+ var hidden = cut.Find(HiddenKp).TextContent;
+ StringAssert.Contains(hidden, "id:1");
+ StringAssert.Contains(hidden, "count:1");
+ });
+
+ await cut.InvokeAsync(() => brouter.Navigate("/mparent/mi/1"));
+ cut.WaitForAssertion(() =>
+ {
+ var visible = cut.Find(VisibleKp).TextContent;
+ StringAssert.Contains(visible, "id:1");
+ StringAssert.Contains(visible, "count:1");
+ });
+ }
+
+ [TestMethod]
+ public async Task ClearKeepAlive_drops_hidden_per_parameter_entries_but_keeps_the_active_one()
+ {
+ var (cut, brouter) = RenderAt("http://localhost/multi/1");
+ cut.WaitForAssertion(() => StringAssert.Contains(cut.Find(VisibleKp).TextContent, "id:1"));
+ cut.Find(VisibleInc).Click();
+
+ await cut.InvokeAsync(() => brouter.Navigate("/multi/2"));
+ cut.WaitForAssertion(() => Assert.AreEqual(2, cut.FindAll("[data-testid=kp]").Count));
+ cut.Find(VisibleInc).Click();
+
+ await cut.InvokeAsync(() => brouter.ClearKeepAlive());
+ cut.WaitForAssertion(() =>
+ {
+ // Only the active (id=2) instance survives, its state intact.
+ Assert.AreEqual(1, cut.FindAll("[data-testid=kp]").Count);
+ var visible = cut.Find(VisibleKp).TextContent;
+ StringAssert.Contains(visible, "id:2");
+ StringAssert.Contains(visible, "count:1");
+ });
+
+ // The dropped id=1 entry really was disposed: returning recreates it fresh.
+ await cut.InvokeAsync(() => brouter.Navigate("/multi/1"));
+ cut.WaitForAssertion(() => StringAssert.Contains(cut.Find(VisibleKp).TextContent, "count:0"));
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/LeaveGuardHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/LeaveGuardHost.razor
new file mode 100644
index 0000000000..1789957a05
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/LeaveGuardHost.razor
@@ -0,0 +1,70 @@
+@* Host for LeaveGuardTests: leave guards at two depths plus a parameterized route, to exercise
+ preventive cancellation, leaf->root ordering, bubbling scope (only deactivated routes fire),
+ and the parameter-change-is-not-a-leave rule. *@
+
+@using Bit.Brouter
+
+
+
+
a
+
+
+
+
b
+
+
+
+
+
+
+
+
+
child1
+
+
+
child2
+
+
+
+
+
+
user @(p.Get("id"))
+
+
+
+@code {
+ // While true, GuardA cancels the navigation away from /a.
+ public bool BlockLeavingA { get; set; }
+
+ // When set, GuardA redirects the navigation away from /a to this URL instead.
+ public string? RedirectLeavingATo { get; set; }
+
+ // Every leave-guard firing in order, e.g. "child1", "parent", recorded with ctx.Route's template.
+ public List Fired { get; } = new();
+
+ private ValueTask GuardA(BrouterNavigationContext ctx)
+ {
+ Fired.Add("a");
+ if (RedirectLeavingATo is not null) ctx.Redirect(RedirectLeavingATo);
+ else if (BlockLeavingA) ctx.Cancel();
+ return ValueTask.CompletedTask;
+ }
+
+ private ValueTask GuardParent(BrouterNavigationContext ctx)
+ {
+ Fired.Add("parent");
+ return ValueTask.CompletedTask;
+ }
+
+ private ValueTask GuardChild1(BrouterNavigationContext ctx)
+ {
+ Fired.Add("child1");
+ return ValueTask.CompletedTask;
+ }
+
+ private ValueTask GuardUser(BrouterNavigationContext ctx)
+ {
+ Fired.Add("user");
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/LeaveGuardTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/LeaveGuardTests.cs
new file mode 100644
index 0000000000..0e9cbb88a0
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/LeaveGuardTests.cs
@@ -0,0 +1,121 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class LeaveGuardTests : BunitTestContext
+{
+ private IRenderedComponent RenderAt(string url)
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo(url);
+ return RenderComponent();
+ }
+
+ [TestMethod]
+ public void Cancelling_leave_guard_keeps_url_and_content()
+ {
+ var nav = Services.GetRequiredService();
+ var cut = RenderAt("http://localhost/a");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ cut.Instance.BlockLeavingA = true;
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsTrue(cut.Instance.Fired.Contains("a"));
+ // Preventive: the navigation never committed - content and URL both still /a.
+ Assert.IsNotNull(cut.Find("[data-testid=a]"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=b]").Count);
+ Assert.IsTrue(nav.Uri.EndsWith("/a", StringComparison.Ordinal));
+ });
+ }
+
+ [TestMethod]
+ public void Non_blocking_leave_guard_lets_the_navigation_proceed()
+ {
+ var cut = RenderAt("http://localhost/a");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ CollectionAssert.AreEqual(new[] { "a" }, cut.Instance.Fired);
+ });
+ }
+
+ [TestMethod]
+ public void Leave_guard_can_redirect()
+ {
+ var nav = Services.GetRequiredService();
+ var cut = RenderAt("http://localhost/a");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ cut.Instance.RedirectLeavingATo = "/parent/child2";
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=child2]"));
+ Assert.IsTrue(nav.Uri.EndsWith("/parent/child2", StringComparison.Ordinal));
+ });
+ }
+
+ [TestMethod]
+ public void Nested_leave_guards_fire_leaf_to_root_when_leaving_the_whole_chain()
+ {
+ var cut = RenderAt("http://localhost/parent/child1");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=child1]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ CollectionAssert.AreEqual(new[] { "child1", "parent" }, cut.Instance.Fired);
+ });
+ }
+
+ [TestMethod]
+ public void Sibling_navigation_fires_only_the_deactivated_childs_guard()
+ {
+ var cut = RenderAt("http://localhost/parent/child1");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=child1]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/parent/child2"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=child2]"));
+ // The parent stays matched under the new URL, so only child1 was "left".
+ CollectionAssert.AreEqual(new[] { "child1" }, cut.Instance.Fired);
+ });
+ }
+
+ [TestMethod]
+ public void Parameter_only_change_on_the_same_route_is_not_a_leave()
+ {
+ var cut = RenderAt("http://localhost/users/1");
+ cut.WaitForAssertion(() => cut.Find("[data-testid=user]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/users/2"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsTrue(cut.Find("[data-testid=user]").TextContent.Contains("2"));
+ Assert.AreEqual(0, cut.Instance.Fired.Count);
+ });
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/LinkHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/LinkHost.razor
index 375391803d..64aec53712 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/LinkHost.razor
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/LinkHost.razor
@@ -4,9 +4,9 @@
@using Bit.Brouter
-
+
page
-
+ ();
+ nav.NavigateTo("http://localhost/cached");
+
+ var cut = RenderComponent(p => p.Add(x => x.StaleTime, TimeSpan.FromMinutes(5)));
+ cut.WaitForAssertion(() => Assert.AreEqual("cached-1", cut.Find("[data-testid=payload]").TextContent));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() => cut.Find("[data-testid=other]"));
+
+ cut.InvokeAsync(() => brouter.Navigate("/cached"));
+
+ cut.WaitForAssertion(() =>
+ {
+ // Rendered from cache: same payload, loader did not run again.
+ Assert.AreEqual("cached-1", cut.Find("[data-testid=payload]").TextContent);
+ Assert.AreEqual(1, cut.Instance.CachedLoaderRuns);
+ });
+ }
+
+ [TestMethod]
+ public void Stale_entry_renders_immediately_then_refreshes_in_background()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/cached");
+
+ // StaleTime zero: every cached entry is immediately stale -> pure stale-while-revalidate.
+ var cut = RenderComponent(p => p.Add(x => x.StaleTime, TimeSpan.Zero));
+ cut.WaitForAssertion(() => Assert.AreEqual("cached-1", cut.Find("[data-testid=payload]").TextContent));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() => cut.Find("[data-testid=other]"));
+
+ cut.InvokeAsync(() => brouter.Navigate("/cached"));
+
+ // The background revalidation re-runs the loader and re-renders with fresh data.
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual("cached-2", cut.Find("[data-testid=payload]").TextContent);
+ Assert.AreEqual(2, cut.Instance.CachedLoaderRuns);
+ });
+ }
+
+ [TestMethod]
+ public void Blocking_mode_treats_stale_as_a_miss()
+ {
+ Services.Configure(o => o.StaleReloadMode = BrouterStaleReloadMode.Blocking);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/cached");
+
+ var cut = RenderComponent(p => p.Add(x => x.StaleTime, TimeSpan.Zero));
+ cut.WaitForAssertion(() => Assert.AreEqual("cached-1", cut.Find("[data-testid=payload]").TextContent));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() => cut.Find("[data-testid=other]"));
+
+ cut.InvokeAsync(() => brouter.Navigate("/cached"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual("cached-2", cut.Find("[data-testid=payload]").TextContent);
+ Assert.AreEqual(2, cut.Instance.CachedLoaderRuns);
+ });
+ }
+
+ [TestMethod]
+ public void No_StaleTime_means_no_caching_loader_reruns_every_navigation()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/cached");
+
+ var cut = RenderComponent(); // StaleTime null
+ cut.WaitForAssertion(() => Assert.AreEqual("cached-1", cut.Find("[data-testid=payload]").TextContent));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() => cut.Find("[data-testid=other]"));
+ cut.InvokeAsync(() => brouter.Navigate("/cached"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual("cached-2", cut.Find("[data-testid=payload]").TextContent);
+ Assert.AreEqual(2, cut.Instance.CachedLoaderRuns);
+ });
+ }
+
+ [TestMethod]
+ public void ClearLoaderCache_forces_a_fresh_load()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/cached");
+
+ var cut = RenderComponent(p => p.Add(x => x.StaleTime, TimeSpan.FromMinutes(5)));
+ cut.WaitForAssertion(() => Assert.AreEqual(1, cut.Instance.CachedLoaderRuns));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/other"));
+ cut.WaitForAssertion(() => cut.Find("[data-testid=other]"));
+
+ brouter.ClearLoaderCache();
+ cut.InvokeAsync(() => brouter.Navigate("/cached"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual("cached-2", cut.Find("[data-testid=payload]").TextContent);
+ Assert.AreEqual(2, cut.Instance.CachedLoaderRuns);
+ });
+ }
+
+ [TestMethod]
+ public void PreloadAsync_warms_the_cache_so_navigation_skips_the_loader()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/other");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=other]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.PreloadAsync("/uncached").AsTask());
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual(1, cut.Instance.UncachedLoaderRuns);
+ Assert.IsTrue(cut.Instance.LastWasPreload!.Value);
+ });
+
+ cut.InvokeAsync(() => brouter.Navigate("/uncached"));
+
+ cut.WaitForAssertion(() =>
+ {
+ // Rendered from the preloaded entry: the loader did not run a second time even though
+ // the route itself declares no StaleTime (PreloadStaleTime covers preload entries).
+ Assert.AreEqual("uncached-1", cut.Find("[data-testid=payload]").TextContent);
+ Assert.AreEqual(1, cut.Instance.UncachedLoaderRuns);
+ });
+ }
+
+ [TestMethod]
+ public void Render_mode_link_preloads_without_any_navigation()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/other");
+
+ var cut = RenderComponent(p => p.Add(x => x.ShowRenderPreloadLink, true));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual(1, cut.Instance.UncachedLoaderRuns);
+ Assert.IsTrue(cut.Instance.LastWasPreload!.Value);
+ // Still on /other - preloading never navigates.
+ Assert.IsNotNull(cut.Find("[data-testid=other]"));
+ });
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/LoaderHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/LoaderHost.razor
index bcecc7ee88..eee100eb03 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/LoaderHost.razor
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/LoaderHost.razor
@@ -1,11 +1,11 @@
@using Bit.Brouter
-
+
-
+
@code {
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/MultiReplaceLinkHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/MultiReplaceLinkHost.razor
new file mode 100644
index 0000000000..f928499db5
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/MultiReplaceLinkHost.razor
@@ -0,0 +1,14 @@
+@* Hosts several Replace links at once so BrouterLinkTests can assert that they share the
+ scope's single bit-brouter.js module import instead of paying one interop import each. *@
+
+@using Bit.Brouter
+
+
+
+
page
+
+
+
+a
+b
+c
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NamedOutletHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/NamedOutletHost.razor
new file mode 100644
index 0000000000..f23604ec1c
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NamedOutletHost.razor
@@ -0,0 +1,35 @@
+@* Host for NamedOutletTests: a dashboard layout with a primary and a named outlet; child /a fills
+ both (Content + a sidebar BrouterView), child /b only the primary, and /u/{id:int} proves route
+ parameters flow into named view fragments. *@
+
+@using Bit.Brouter
+
+
+
+
+
+
+
+
+
+
-
+
@code {
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NavigateAsyncHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/NavigateAsyncHost.razor
new file mode 100644
index 0000000000..7f40697570
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NavigateAsyncHost.razor
@@ -0,0 +1,53 @@
+@* Host for NavigateAsyncTests: one route per BrouterNavigationOutcome shape - plain success,
+ guard-cancelled, guard-redirected, RedirectTo route, failing loader, and (implicitly) any
+ unmatched URL for NotFound. *@
+
+@using Bit.Brouter
+
+
+
+
a
+
+
+
b
+
+
+
blocked
+
+
+
redirect
+
+
+
+
fail
+
+
+
slow
+
+
+
+@code {
+ // Held by SlowLoader until a test releases it, so a navigation can be kept in-flight.
+ public TaskCompletionSource SlowGate { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private async ValueTask SlowLoader(BrouterNavigationContext ctx)
+ {
+ await SlowGate.Task;
+ return "slow data";
+ }
+
+ private ValueTask BlockGuard(BrouterNavigationContext ctx)
+ {
+ ctx.Cancel();
+ return ValueTask.CompletedTask;
+ }
+
+ private ValueTask RedirectGuard(BrouterNavigationContext ctx)
+ {
+ ctx.Redirect("/b");
+ return ValueTask.CompletedTask;
+ }
+
+ private ValueTask FailLoader(BrouterNavigationContext ctx)
+ => throw new InvalidOperationException("loader exploded");
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NavigateAsyncTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/NavigateAsyncTests.cs
new file mode 100644
index 0000000000..9d7b0b4898
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NavigateAsyncTests.cs
@@ -0,0 +1,130 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class NavigateAsyncTests : BunitTestContext
+{
+ private (IRenderedComponent Cut, IBrouter Brouter) RenderAtA()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+ return (cut, Services.GetRequiredService());
+ }
+
+ private BrouterNavigationOutcome Await(IRenderedComponent cut, ValueTask navigation)
+ {
+ var task = navigation.AsTask();
+ cut.WaitForAssertion(() => Assert.IsTrue(task.IsCompleted, "navigation outcome did not resolve"));
+ return task.GetAwaiter().GetResult();
+ }
+
+ [TestMethod]
+ public async Task Successful_navigation_resolves_Succeeded()
+ {
+ var (cut, brouter) = RenderAtA();
+
+ ValueTask navigation = default;
+ await cut.InvokeAsync(() => { navigation = brouter.NavigateAsync("/b"); });
+ var outcome = Await(cut, navigation);
+
+ Assert.AreEqual(BrouterNavigationStatus.Succeeded, outcome.Status);
+ Assert.IsTrue(outcome.Succeeded);
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ }
+
+ [TestMethod]
+ public async Task Guard_cancel_resolves_Cancelled_and_url_stays()
+ {
+ var (cut, brouter) = RenderAtA();
+ var nav = Services.GetRequiredService();
+
+ ValueTask navigation = default;
+ await cut.InvokeAsync(() => { navigation = brouter.NavigateAsync("/blocked"); });
+ var outcome = Await(cut, navigation);
+
+ Assert.AreEqual(BrouterNavigationStatus.Cancelled, outcome.Status);
+ Assert.IsTrue(nav.Uri.EndsWith("/a", StringComparison.Ordinal));
+ }
+
+ [TestMethod]
+ public async Task Guard_redirect_resolves_Redirected_with_the_target()
+ {
+ var (cut, brouter) = RenderAtA();
+
+ ValueTask navigation = default;
+ await cut.InvokeAsync(() => { navigation = brouter.NavigateAsync("/redirect"); });
+ var outcome = Await(cut, navigation);
+
+ Assert.AreEqual(BrouterNavigationStatus.Redirected, outcome.Status);
+ Assert.AreEqual("/b", outcome.RedirectedTo);
+ cut.WaitForAssertion(() => cut.Find("[data-testid=b]"));
+ }
+
+ [TestMethod]
+ public async Task RedirectTo_route_resolves_Redirected()
+ {
+ var (cut, brouter) = RenderAtA();
+
+ ValueTask navigation = default;
+ await cut.InvokeAsync(() => { navigation = brouter.NavigateAsync("/route-redirect"); });
+ var outcome = Await(cut, navigation);
+
+ Assert.AreEqual(BrouterNavigationStatus.Redirected, outcome.Status);
+ Assert.AreEqual("/b", outcome.RedirectedTo);
+ cut.WaitForAssertion(() => cut.Find("[data-testid=b]"));
+ }
+
+ [TestMethod]
+ public async Task Failing_loader_resolves_Failed_with_the_exception()
+ {
+ var (cut, brouter) = RenderAtA();
+
+ ValueTask navigation = default;
+ await cut.InvokeAsync(() => { navigation = brouter.NavigateAsync("/fail"); });
+ var outcome = Await(cut, navigation);
+
+ Assert.AreEqual(BrouterNavigationStatus.Failed, outcome.Status);
+ Assert.IsInstanceOfType(outcome.Exception);
+ Assert.IsTrue(outcome.Exception!.Message.Contains("loader exploded"));
+ }
+
+ [TestMethod]
+ public async Task Unmatched_url_resolves_NotFound()
+ {
+ var (cut, brouter) = RenderAtA();
+
+ ValueTask navigation = default;
+ await cut.InvokeAsync(() => { navigation = brouter.NavigateAsync("/nope/nothing/here"); });
+ var outcome = Await(cut, navigation);
+
+ Assert.AreEqual(BrouterNavigationStatus.NotFound, outcome.Status);
+ }
+
+ [TestMethod]
+ public async Task A_newer_navigation_supersedes_an_in_flight_awaiter()
+ {
+ var (cut, brouter) = RenderAtA();
+
+ // First navigation parks inside the /slow loader, genuinely in-flight...
+ ValueTask first = default;
+ await cut.InvokeAsync(() => { first = brouter.NavigateAsync("/slow"); });
+
+ // ...then a second navigation starts before the first's loader finishes.
+ ValueTask second = default;
+ await cut.InvokeAsync(() => { second = brouter.NavigateAsync("/b"); });
+
+ var firstOutcome = Await(cut, first);
+ var secondOutcome = Await(cut, second);
+ cut.Instance.SlowGate.TrySetResult(); // release the parked loader so nothing leaks
+
+ Assert.AreEqual(BrouterNavigationStatus.Superseded, firstOutcome.Status);
+ Assert.AreEqual(BrouterNavigationStatus.Succeeded, secondOutcome.Status);
+ cut.WaitForAssertion(() => cut.Find("[data-testid=b]"));
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NavigationEffectsHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/NavigationEffectsHost.razor
new file mode 100644
index 0000000000..d1c561b412
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NavigationEffectsHost.razor
@@ -0,0 +1,14 @@
+@* Host for post-navigation DOM effect tests (fragment scroll, scroll-to-top, focus). Two literal
+ routes; the docs page renders an #install anchor so fragment navigation has a real target. *@
+
+
+
+
home
+
+
+
+
docs
+ install
+
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NavigationEffectsTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/NavigationEffectsTests.cs
new file mode 100644
index 0000000000..4943015af1
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NavigationEffectsTests.cs
@@ -0,0 +1,246 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class NavigationEffectsTests : BunitTestContext
+{
+ private const string ModuleUrl = "./_content/Bit.Brouter/bit-brouter.js";
+
+ [TestMethod]
+ public void Fragment_navigation_invokes_applyNavigationEffects_with_the_hash()
+ {
+ // Default options: ScrollToFragment = true. Navigating to /docs#install should hand the
+ // fragment to the JS effect so it can scroll #install into view.
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/docs#install");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var invocation = module.Invocations["applyNavigationEffects"].Single();
+ Assert.AreEqual("#install", invocation.Arguments[0]); // hash
+ Assert.IsNull(invocation.Arguments[1]); // focus selector (unset)
+ Assert.AreEqual(false, invocation.Arguments[2]); // scrollToTop
+ });
+ }
+
+ [TestMethod]
+ public void Fragment_scroll_can_be_disabled()
+ {
+ // With ScrollToFragment = false and nothing else configured, a fragment navigation has no
+ // DOM effect to apply, so the JS module is never even invoked.
+ Services.Configure(o => o.ScrollToFragment = false);
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/docs#install");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=docs]")));
+ Assert.AreEqual(0, module.Invocations["applyNavigationEffects"].Count);
+ }
+
+ [TestMethod]
+ public void Focus_selector_is_forwarded_after_navigation()
+ {
+ // FocusOnNavigateSelector configured -> the selector is forwarded so the JS effect can move
+ // focus for assistive technologies, even on a fragment-less navigation.
+ Services.Configure(o => o.FocusOnNavigateSelector = "h1");
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var invocation = module.Invocations["applyNavigationEffects"].Single();
+ Assert.IsNull(invocation.Arguments[0]); // no hash
+ Assert.AreEqual("h1", invocation.Arguments[1]); // focus selector
+ Assert.AreEqual(false, invocation.Arguments[2]); // scrollToTop
+ });
+ }
+
+ [TestMethod]
+ public void ScrollToTop_is_forwarded_when_configured()
+ {
+ Services.Configure(o => o.ScrollBehavior = BrouterScrollMode.ToTop);
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var invocation = module.Invocations["applyNavigationEffects"].Single();
+ Assert.IsNull(invocation.Arguments[0]); // no hash
+ Assert.IsNull(invocation.Arguments[1]); // no focus selector
+ Assert.AreEqual(true, invocation.Arguments[2]); // scrollToTop
+ });
+ }
+
+ [TestMethod]
+ public void No_effects_configured_does_not_invoke_the_module()
+ {
+ // Default options with a fragment-less navigation: ScrollBehavior = None, no focus selector,
+ // and ScrollToFragment has no fragment to act on. The JS module must not be imported/called.
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=home]")));
+ Assert.AreEqual(0, module.Invocations["applyNavigationEffects"].Count);
+ }
+
+ [TestMethod]
+ public void Restore_scroll_forwards_the_destination_url_as_restore_key()
+ {
+ // With RestoreScrollPosition enabled the destination's absolute URL is forwarded as the
+ // restore key so the JS side can look up (on a Back/Forward) the position saved for it.
+ Services.Configure(o => o.RestoreScrollPosition = true);
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var invocation = module.Invocations["applyNavigationEffects"].Last();
+ Assert.AreEqual("http://localhost/home", invocation.Arguments[3]); // restore key = destination URL
+ });
+ }
+
+ [TestMethod]
+ public void Restore_scroll_disabled_sends_null_restore_key_and_never_saves()
+ {
+ // Restoration off: the restore key must be null (so the JS side leaves browser-native
+ // restoration untouched) and no outgoing position is ever saved. ScrollBehavior.ToTop is set
+ // only to force the module to be invoked so we can inspect the forwarded arguments.
+ Services.Configure(o => o.ScrollBehavior = BrouterScrollMode.ToTop);
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var invocation = module.Invocations["applyNavigationEffects"].Last();
+ Assert.IsNull(invocation.Arguments[3]); // no restore key
+ });
+ Assert.AreEqual(0, module.Invocations["saveScrollPosition"].Count);
+ }
+
+ [TestMethod]
+ public void Restore_scroll_forwards_the_configured_storage_kind()
+ {
+ // LocalStorage persistence -> the "local" token is forwarded to both interop entry points so
+ // the JS side mirrors positions into localStorage (and hydrates them on reload).
+ Services.Configure(o =>
+ {
+ o.RestoreScrollPosition = true;
+ o.ScrollPositionStorage = BrouterScrollPositionStorage.LocalStorage;
+ });
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() =>
+ {
+ var effects = module.Invocations["applyNavigationEffects"].Last();
+ Assert.AreEqual("local", effects.Arguments[4]); // storage kind on the effects call
+ });
+
+ nav.NavigateTo("http://localhost/docs");
+ cut.WaitForAssertion(() =>
+ {
+ var save = module.Invocations["saveScrollPosition"].Single();
+ Assert.AreEqual("local", save.Arguments[1]); // storage kind on the save call
+ });
+ }
+
+ [TestMethod]
+ public void Restore_scroll_defaults_to_in_memory_storage()
+ {
+ // Restoration on but storage left at the default -> null storage kind (in-memory only).
+ Services.Configure(o => o.RestoreScrollPosition = true);
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() =>
+ {
+ var effects = module.Invocations["applyNavigationEffects"].Last();
+ Assert.IsNull(effects.Arguments[4]); // no persistence
+ });
+ }
+
+ [TestMethod]
+ public void Restore_scroll_saves_the_outgoing_page_on_navigation_away()
+ {
+ // The scroll position is saved for the page being LEFT, keyed by its URL, and only once there
+ // is a real page to leave: the initial load (from == Empty) saves nothing.
+ Services.Configure(o => o.RestoreScrollPosition = true);
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=home]")));
+
+ // Initial load has no page to leave -> nothing saved yet.
+ Assert.AreEqual(0, module.Invocations["saveScrollPosition"].Count);
+
+ nav.NavigateTo("http://localhost/docs");
+
+ cut.WaitForAssertion(() =>
+ {
+ var save = module.Invocations["saveScrollPosition"].Single();
+ Assert.AreEqual("http://localhost/home", save.Arguments[0]); // saved the page we left
+ });
+ }
+
+ [TestMethod]
+ public void Fragment_takes_precedence_over_scroll_to_top()
+ {
+ // Both a fragment and ScrollBehavior.ToTop are in play. The service still forwards both
+ // flags; the JS side is responsible for letting the fragment win. Assert the hash and the
+ // scrollToTop flag are both forwarded so that precedence decision lives in one place (JS).
+ Services.Configure(o => o.ScrollBehavior = BrouterScrollMode.ToTop);
+ var module = Context!.JSInterop.SetupModule(ModuleUrl);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/docs#install");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ var invocation = module.Invocations["applyNavigationEffects"].Single();
+ Assert.AreEqual("#install", invocation.Arguments[0]);
+ Assert.AreEqual(true, invocation.Arguments[2]);
+ });
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NavigationTypeHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/NavigationTypeHost.razor
new file mode 100644
index 0000000000..bdb354bda4
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NavigationTypeHost.razor
@@ -0,0 +1,31 @@
+@using Bit.Brouter
+@implements IDisposable
+@inject IBrouter Brouter
+
+
+
+
a
+
+
+
b
+
+
+
+@code {
+ // The navigation type of the most recent completed navigation, recorded from the OnNavigated hook.
+ public BrouterNavigationType? LastType { get; private set; }
+
+ // Every recorded type in order, so a test can assert the whole sequence if it needs to.
+ public List Types { get; } = new();
+
+ protected override void OnInitialized() => Brouter.OnNavigated += OnNavigated;
+
+ private ValueTask OnNavigated(BrouterNavigationContext ctx)
+ {
+ LastType = ctx.NavigationType;
+ Types.Add(ctx.NavigationType);
+ return ValueTask.CompletedTask;
+ }
+
+ public void Dispose() => Brouter.OnNavigated -= OnNavigated;
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NavigationTypeTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/NavigationTypeTests.cs
new file mode 100644
index 0000000000..78dd992037
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NavigationTypeTests.cs
@@ -0,0 +1,80 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class NavigationTypeTests : BunitTestContext
+{
+ [TestMethod]
+ public void Initial_load_is_reported_as_Push()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() => Assert.AreEqual(BrouterNavigationType.Push, cut.Instance.LastType));
+ }
+
+ [TestMethod]
+ public void Programmatic_navigate_is_a_Push()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.AreEqual(BrouterNavigationType.Push, cut.Instance.LastType));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ Assert.AreEqual(BrouterNavigationType.Push, cut.Instance.LastType);
+ });
+ }
+
+ [TestMethod]
+ public void Programmatic_navigate_with_replace_is_a_Replace()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.AreEqual(BrouterNavigationType.Push, cut.Instance.LastType));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b", replace: true));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ Assert.AreEqual(BrouterNavigationType.Replace, cut.Instance.LastType);
+ });
+ }
+
+ [TestMethod]
+ public void Non_intercepted_navigation_that_bypasses_IBrouter_is_reported_as_Pop()
+ {
+ // A raw NavigationManager.NavigateTo that does not go through IBrouter and is not an intercepted
+ // link click is indistinguishable, at the framework level, from a browser Back/Forward. It is
+ // therefore reported as Pop - the same classification a genuine history traversal receives.
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.AreEqual(BrouterNavigationType.Push, cut.Instance.LastType));
+
+ cut.InvokeAsync(() => nav.NavigateTo("http://localhost/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ Assert.AreEqual(BrouterNavigationType.Pop, cut.Instance.LastType);
+ });
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NestedIndexHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/NestedIndexHost.razor
new file mode 100644
index 0000000000..be3af77261
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NestedIndexHost.razor
@@ -0,0 +1,15 @@
+@* A parent route with an index child. Their full templates are identical ("docs"), which is the
+ documented nested-index pattern (resolved by the depth/index tiebreaks) and must NOT be
+ rejected as ambiguous at registration. Used by AmbiguousRouteTests. *@
+@using Bit.Brouter
+
+
+
+
+
+
index
+
+
+
docs
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NotFoundInteropHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/NotFoundInteropHost.razor
new file mode 100644
index 0000000000..8c76226c8c
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NotFoundInteropHost.razor
@@ -0,0 +1,36 @@
+@* Host for NotFoundInteropTests: a page route, a 404 page route, and inline NotFoundContent, so
+ tests can exercise NavigationManager.NotFound() flowing through Brouter's OnNotFound event
+ handling in both the redirect (NotFoundUrl set) and render-in-place (inline) shapes. Brouter
+ only renders the inline fragment when no NotFound URL redirect applies, so both shapes can
+ coexist here. *@
+
+@using Bit.Brouter
+@inject IBrouter Brouter
+
+
+
+
+
a
+
+
+
404 page
+
+
+
+
inline 404 for @loc.Path
+
+
+
+@code {
+ [Parameter] public string? NotFoundUrl { get; set; }
+
+ public int NotFoundHookCount { get; private set; }
+ public string? LastNotFoundPath { get; private set; }
+
+ private ValueTask RecordNotFound(BrouterLocation location)
+ {
+ NotFoundHookCount++;
+ LastNotFoundPath = location.Path;
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NotFoundInteropTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/NotFoundInteropTests.cs
new file mode 100644
index 0000000000..ca43950165
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/NotFoundInteropTests.cs
@@ -0,0 +1,59 @@
+#if NET10_0_OR_GREATER
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+///
+/// .NET 10 not-found contract: application code calls NavigationManager.NotFound() and the
+/// router is expected to take over rendering via the OnNotFound event. These tests only run
+/// on net10.0 because the API doesn't exist on earlier targets.
+///
+[TestClass]
+public class NotFoundInteropTests : BunitTestContext
+{
+ [TestMethod]
+ public void NotFound_call_renders_inline_content_without_changing_the_url()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ cut.InvokeAsync(() => nav.NotFound());
+
+ cut.WaitForAssertion(() =>
+ {
+ // The route content is replaced by the inline fallback; the URL stays on /a because
+ // the resource at this URL is what is missing, mirroring the built-in router.
+ Assert.IsNotNull(cut.Find("[data-testid=nf-inline]"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=a]").Count);
+ Assert.IsTrue(nav.Uri.EndsWith("/a", StringComparison.Ordinal));
+ Assert.AreEqual(1, cut.Instance.NotFoundHookCount);
+ Assert.AreEqual("/a", cut.Instance.LastNotFoundPath);
+ });
+ }
+
+ [TestMethod]
+ public void NotFound_call_redirects_to_the_configured_NotFound_url()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+
+ var cut = RenderComponent(p => p.Add(x => x.NotFoundUrl, "/404"));
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ cut.InvokeAsync(() => nav.NotFound());
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=nf-page]"));
+ Assert.IsTrue(nav.Uri.EndsWith("/404", StringComparison.Ordinal));
+ Assert.AreEqual(1, cut.Instance.NotFoundHookCount);
+ });
+ }
+}
+#endif
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/OnNavigateAsyncTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/OnNavigateAsyncTests.cs
new file mode 100644
index 0000000000..f4dd8c2cec
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/OnNavigateAsyncTests.cs
@@ -0,0 +1,58 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class OnNavigateAsyncTests : BunitTestContext
+{
+ [TestMethod]
+ public void Assembly_returned_by_the_hook_matches_within_the_same_navigation()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=home]"));
+
+ // /discovered/7 belongs to a page the router has never seen: the hook loads its assembly
+ // mid-navigation and the SAME navigation matches it.
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/discovered/7"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=discovered]"));
+ Assert.IsTrue(cut.Find("[data-testid=discovered]").TextContent.Contains("id:7"));
+ });
+ }
+
+ [TestMethod]
+ public void Hook_runs_for_the_initial_deep_link_too()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/discovered/3");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=discovered]"));
+ Assert.IsTrue(cut.Instance.HookPaths.Contains("/discovered/3"));
+ });
+ }
+
+ [TestMethod]
+ public void Hook_returning_null_changes_nothing()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/home");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=home]"));
+
+ Assert.IsTrue(cut.Instance.HookRuns >= 1);
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/OnNavigateHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/OnNavigateHost.razor
new file mode 100644
index 0000000000..0e337a20a5
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/OnNavigateHost.razor
@@ -0,0 +1,32 @@
+@* Host for OnNavigateAsyncTests: no AppAssembly is configured, so the @page "/discovered/{id:int}"
+ component in this test assembly is initially unknown; the OnNavigateAsync hook "lazily loads"
+ the assembly when a /discovered URL is targeted. *@
+
+@using Bit.Brouter
+@using System.Reflection
+
+
+
+
home
+
+
+
+@code {
+ public int HookRuns { get; private set; }
+ public List HookPaths { get; } = new();
+
+ private ValueTask?> LoadAssemblies(BrouterNavigationContext ctx)
+ {
+ HookRuns++;
+ HookPaths.Add(ctx.To.Path);
+
+ // Simulate LazyAssemblyLoader: hand the router the assembly containing the lazily-loaded
+ // pages only when a URL that needs it comes through.
+ if (ctx.To.Path.StartsWith("/discovered", StringComparison.OrdinalIgnoreCase))
+ {
+ return ValueTask.FromResult?>([typeof(OnNavigateHost).Assembly]);
+ }
+
+ return ValueTask.FromResult?>(null);
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/OptionalParamHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/OptionalParamHost.razor
index b0e69844ab..66e882879a 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/OptionalParamHost.razor
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/OptionalParamHost.razor
@@ -1,7 +1,7 @@
@using Bit.Brouter
-
+
@(ctx["id"] ?? "(none)")
-
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ParallelLoadersHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/ParallelLoadersHost.razor
new file mode 100644
index 0000000000..f6862d0eef
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ParallelLoadersHost.razor
@@ -0,0 +1,30 @@
+@using Bit.Brouter
+
+@* The parent's loader only completes after the child's loader has STARTED, so this host can
+ only finish rendering when the chain's loaders overlap. Under sequential execution the
+ parent would wait forever (the child never starts), and the WaitAsync timeout would fail
+ the loader instead of hanging the test run. *@
+
+
+
+
+
+
+
+
+link
+
+@code {
+ [Parameter, EditorRequired] public string Href { get; set; } = "/";
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/RelativeNavigationTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/RelativeNavigationTests.cs
new file mode 100644
index 0000000000..32b2d4280d
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/RelativeNavigationTests.cs
@@ -0,0 +1,112 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class RelativeNavigationTests : BunitTestContext
+{
+ [TestMethod]
+ public void IsRelative_accepts_only_dot_prefixed_paths()
+ {
+ Assert.IsTrue(BrouterRelativeUrl.IsRelative("."));
+ Assert.IsTrue(BrouterRelativeUrl.IsRelative(".."));
+ Assert.IsTrue(BrouterRelativeUrl.IsRelative("./edit"));
+ Assert.IsTrue(BrouterRelativeUrl.IsRelative("../sibling"));
+ Assert.IsTrue(BrouterRelativeUrl.IsRelative("../../x"));
+
+ // A query or hash directly after the dot form is still relative (Resolve preserves it).
+ Assert.IsTrue(BrouterRelativeUrl.IsRelative(".?tab=info"));
+ Assert.IsTrue(BrouterRelativeUrl.IsRelative("..?tab=info"));
+ Assert.IsTrue(BrouterRelativeUrl.IsRelative(".#top"));
+ Assert.IsTrue(BrouterRelativeUrl.IsRelative("..#top"));
+
+ // Bare and dotted-but-not-relative names keep their base-relative meaning.
+ Assert.IsFalse(BrouterRelativeUrl.IsRelative("sibling"));
+ Assert.IsFalse(BrouterRelativeUrl.IsRelative("/absolute"));
+ Assert.IsFalse(BrouterRelativeUrl.IsRelative(".well-known"));
+ Assert.IsFalse(BrouterRelativeUrl.IsRelative("..foo"));
+ Assert.IsFalse(BrouterRelativeUrl.IsRelative(""));
+ }
+
+ [TestMethod]
+ public void Resolve_uses_segment_math_against_the_current_path()
+ {
+ Assert.AreEqual("/users/42/edit", BrouterRelativeUrl.Resolve("/users/42", "./edit"));
+ Assert.AreEqual("/users/7", BrouterRelativeUrl.Resolve("/users/42", "../7"));
+ Assert.AreEqual("/users", BrouterRelativeUrl.Resolve("/users/42", ".."));
+ Assert.AreEqual("/users/42", BrouterRelativeUrl.Resolve("/users/42", "."));
+ Assert.AreEqual("/admin", BrouterRelativeUrl.Resolve("/users/42", "../../admin"));
+ Assert.AreEqual("/x", BrouterRelativeUrl.Resolve("/", "./x"));
+ }
+
+ [TestMethod]
+ public void Resolve_clamps_excess_parent_references_at_the_root()
+ {
+ Assert.AreEqual("/x", BrouterRelativeUrl.Resolve("/users/42", "../../../../x"));
+ Assert.AreEqual("/", BrouterRelativeUrl.Resolve("/users", "../.."));
+ }
+
+ [TestMethod]
+ public void Resolve_preserves_query_and_hash()
+ {
+ Assert.AreEqual("/users/7?tab=info#top", BrouterRelativeUrl.Resolve("/users/42", "../7?tab=info#top"));
+ Assert.AreEqual("/users/42?tab=info", BrouterRelativeUrl.Resolve("/users/42", ".?tab=info"));
+ }
+
+ [TestMethod]
+ public void Navigate_resolves_relative_url_against_current_location()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/users/42");
+
+ var cut = RenderComponent(p => p
+ .Add(h => h.Name, "user")
+ .Add(h => h.Path, "/users/{id}"));
+
+ var brouter = Services.GetRequiredService();
+ // Ensure the initial location has committed before resolving against it.
+ cut.WaitForAssertion(() => Assert.AreEqual("/users/42", brouter.Location.Path));
+
+ brouter.Navigate("../7");
+
+ cut.WaitForAssertion(() => StringAssert.EndsWith(nav.Uri, "/users/7"));
+ }
+
+ [TestMethod]
+ public void Guard_redirect_resolves_relative_url_against_the_target_location()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/admin/secret");
+
+ var cut = RenderComponent();
+
+ cut.WaitForAssertion(() =>
+ {
+ StringAssert.EndsWith(nav.Uri, "/admin/login");
+ Assert.IsNotNull(cut.Find("[data-testid=login]"));
+ });
+ }
+
+ [TestMethod]
+ public void Link_renders_resolved_href_and_re_resolves_after_navigation()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/users/42");
+
+ // RelativeLinkHost's catch-all route matches every path, so each navigation fires
+ // OnNavigated and the link re-resolves (links only refresh on matched navigations).
+ var cut = RenderComponent(p => p.Add(x => x.Href, "../sibling"));
+
+ cut.WaitForAssertion(() =>
+ Assert.AreEqual("/users/sibling", cut.Find("[data-testid=link]").GetAttribute("href")));
+
+ // A route-relative link points somewhere new after navigating; the DOM href must follow.
+ nav.NavigateTo("http://localhost/a/b/c");
+
+ cut.WaitForAssertion(() =>
+ Assert.AreEqual("/a/b/sibling", cut.Find("[data-testid=link]").GetAttribute("href")));
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/RelativeRedirectHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/RelativeRedirectHost.razor
new file mode 100644
index 0000000000..0623b3ec16
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/RelativeRedirectHost.razor
@@ -0,0 +1,20 @@
+@* Guard redirects with a route-relative URL: "../login" from /admin/secret must land on /admin/login. *@
+
+@using Bit.Brouter
+
+
+
+
should not render
+
+
+
login
+
+
+
+@code {
+ private ValueTask Deny(BrouterNavigationContext ctx)
+ {
+ ctx.Redirect("../login");
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/RenderInvalidationHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/RenderInvalidationHost.razor
new file mode 100644
index 0000000000..052ecc1b9e
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/RenderInvalidationHost.razor
@@ -0,0 +1,26 @@
+@* Sibling routes plus a three-level nested chain, used by RenderInvalidationTests to pin the
+ render-invalidation contract: navigating between routes must unrender the losing route, and a
+ single render request at the matched chain's top must be enough to render a deep leaf. *@
+@using Bit.Brouter
+
+
+
+
plain
+
+
+
other
+
+
+
+
+
+
+
leaf
+
+
+
mid
+
+
+
top:@(p.GetOrDefault("id"))
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/RenderInvalidationTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/RenderInvalidationTests.cs
new file mode 100644
index 0000000000..fb9a766ac0
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/RenderInvalidationTests.cs
@@ -0,0 +1,74 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+///
+/// Pins the render-invalidation contract that SetMatched relies on: a single render request at
+/// the top of the matched chain must reveal the whole chain (down to a deep leaf), and the
+/// pipeline's final render must unrender routes that lost the match.
+///
+[TestClass]
+public class RenderInvalidationTests : BunitTestContext
+{
+ [TestMethod]
+ public void Navigating_between_siblings_unrenders_the_losing_route()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/plain");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=plain]")));
+
+ nav.NavigateTo("http://localhost/other");
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=other]"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=plain]").Count,
+ "the previously matched sibling must be unrendered");
+ });
+ }
+
+ [TestMethod]
+ public void Deep_chain_renders_every_level_after_navigating_in_from_a_sibling()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/plain");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.IsNotNull(cut.Find("[data-testid=plain]")));
+
+ nav.NavigateTo("http://localhost/l1/42/l2/l3");
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual("top:42", cut.Find("[data-testid=top]").TextContent);
+ Assert.IsNotNull(cut.Find("[data-testid=mid]"));
+ Assert.IsNotNull(cut.Find("[data-testid=leaf]"));
+ Assert.AreEqual(0, cut.FindAll("[data-testid=plain]").Count);
+ });
+ }
+
+ [TestMethod]
+ public void Deep_chain_re_renders_with_new_parameters_when_only_a_parameter_changes()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/l1/1/l2/l3");
+
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.AreEqual("top:1", cut.Find("[data-testid=top]").TextContent));
+
+ // Same chain matches again; only the bound parameter changes. The single render request
+ // at the chain's top must still propagate the new parameter into the rendered content.
+ nav.NavigateTo("http://localhost/l1/2/l2/l3");
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual("top:2", cut.Find("[data-testid=top]").TextContent);
+ Assert.IsNotNull(cut.Find("[data-testid=leaf]"));
+ });
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ResolveUrlTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/ResolveUrlTests.cs
index 787ecf624e..b3d4cf68f2 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/ResolveUrlTests.cs
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ResolveUrlTests.cs
@@ -188,6 +188,74 @@ public void Query_string_is_appended_with_leading_question_mark_added_when_missi
Assert.AreEqual("/users/1?tab=info", withoutPrefix);
}
+ [TestMethod]
+ public void Unmatched_parameters_are_appended_as_query_string()
+ {
+ var brouter = MountWithNamedRoute("user", "/users/{id}");
+
+ var url = brouter.ResolveUrl("user",
+ new Dictionary { ["id"] = 42, ["tab"] = "info", ["page"] = 2 });
+
+ Assert.AreEqual("/users/42?tab=info&page=2", url);
+ }
+
+ [TestMethod]
+ public void Unmatched_parameters_are_appended_after_explicit_query()
+ {
+ var brouter = MountWithNamedRoute("user", "/users/{id}");
+
+ var url = brouter.ResolveUrl("user",
+ new Dictionary { ["id"] = 1, ["tab"] = "info" },
+ query: "sort=asc");
+
+ Assert.AreEqual("/users/1?sort=asc&tab=info", url);
+ }
+
+ [TestMethod]
+ public void Unmatched_parameter_with_null_value_is_skipped()
+ {
+ var brouter = MountWithNamedRoute("user", "/users/{id}");
+
+ var url = brouter.ResolveUrl("user",
+ new Dictionary { ["id"] = 1, ["tab"] = null });
+
+ Assert.AreEqual("/users/1", url);
+ }
+
+ [TestMethod]
+ public void Unmatched_parameter_key_and_value_are_percent_encoded()
+ {
+ var brouter = MountWithNamedRoute("user", "/users/{id}");
+
+ var url = brouter.ResolveUrl("user",
+ new Dictionary { ["id"] = 1, ["full name"] = "john & jane" });
+
+ Assert.AreEqual("/users/1?full%20name=john%20%26%20jane", url);
+ }
+
+ [TestMethod]
+ public void Unmatched_enumerable_parameter_emits_one_pair_per_element()
+ {
+ var brouter = MountWithNamedRoute("user", "/users/{id}");
+
+ var url = brouter.ResolveUrl("user",
+ new Dictionary { ["id"] = 1, ["tag"] = new[] { "a", "b" } });
+
+ Assert.AreEqual("/users/1?tag=a&tag=b", url);
+ }
+
+ [TestMethod]
+ public void Parameter_consumed_by_optional_segment_is_not_duplicated_in_query()
+ {
+ var brouter = MountWithNamedRoute("profile", "/profile/{username?}");
+
+ // The username binds to the optional segment; only the leftover key becomes query.
+ var url = brouter.ResolveUrl("profile",
+ new Dictionary { ["username"] = "saleh", ["tab"] = "posts" });
+
+ Assert.AreEqual("/profile/saleh?tab=posts", url);
+ }
+
[TestMethod]
public void Boolean_is_formatted_as_lowercase()
{
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/RevalidateHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/RevalidateHost.razor
new file mode 100644
index 0000000000..3f23dfde09
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/RevalidateHost.razor
@@ -0,0 +1,48 @@
+@* Host for RevalidateTests: a parent/child chain whose loaders count invocations and expose an
+ incrementing value, plus a guard counter proving revalidation never re-runs guards. *@
+
+@using Bit.Brouter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
other
+
+
+
+@code {
+ public int GuardRuns { get; private set; }
+ public int ParentLoaderRuns { get; private set; }
+ public int ChildLoaderRuns { get; private set; }
+ public bool? LastLoadWasRevalidation { get; private set; }
+
+ private ValueTask CountGuard(BrouterNavigationContext ctx)
+ {
+ GuardRuns++;
+ return ValueTask.CompletedTask;
+ }
+
+ private ValueTask ParentLoader(BrouterNavigationContext ctx)
+ {
+ ParentLoaderRuns++;
+ return ValueTask.FromResult($"parent-{ParentLoaderRuns}");
+ }
+
+ private ValueTask ChildLoader(BrouterNavigationContext ctx)
+ {
+ ChildLoaderRuns++;
+ LastLoadWasRevalidation = ctx.IsRevalidation;
+ return ValueTask.FromResult($"child-{ChildLoaderRuns}");
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/RevalidateTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/RevalidateTests.cs
new file mode 100644
index 0000000000..e6cbe8b735
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/RevalidateTests.cs
@@ -0,0 +1,84 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class RevalidateTests : BunitTestContext
+{
+ private (IRenderedComponent Cut, IBrouter Brouter) RenderAtData()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/data/view");
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => Assert.IsTrue(cut.Find("[data-testid=child-data]").TextContent.Contains("child-1")));
+ return (cut, Services.GetRequiredService());
+ }
+
+ [TestMethod]
+ public void Revalidate_reruns_all_chain_loaders_and_rerenders_fresh_data()
+ {
+ var (cut, brouter) = RenderAtData();
+ Assert.AreEqual(1, cut.Instance.ParentLoaderRuns);
+ Assert.AreEqual(1, cut.Instance.ChildLoaderRuns);
+
+ cut.InvokeAsync(() => brouter.RevalidateAsync().AsTask());
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual(2, cut.Instance.ParentLoaderRuns);
+ Assert.AreEqual(2, cut.Instance.ChildLoaderRuns);
+ // The cascaded data refreshed in the DOM.
+ Assert.IsTrue(cut.Find("[data-testid=child-data]").TextContent.Contains("child-2"));
+ });
+ }
+
+ [TestMethod]
+ public void Revalidate_does_not_rerun_guards_and_flags_the_context()
+ {
+ var (cut, brouter) = RenderAtData();
+ var guardRunsAfterNavigation = cut.Instance.GuardRuns;
+ Assert.IsFalse(cut.Instance.LastLoadWasRevalidation!.Value);
+
+ cut.InvokeAsync(() => brouter.RevalidateAsync().AsTask());
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual(2, cut.Instance.ChildLoaderRuns);
+ Assert.AreEqual(guardRunsAfterNavigation, cut.Instance.GuardRuns);
+ Assert.IsTrue(cut.Instance.LastLoadWasRevalidation!.Value);
+ });
+ }
+
+ [TestMethod]
+ public async Task Revalidate_with_no_matched_chain_is_a_noop()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/nowhere");
+ var cut = RenderComponent();
+
+ var brouter = Services.GetRequiredService();
+ await cut.InvokeAsync(() => brouter.RevalidateAsync().AsTask());
+
+ Assert.AreEqual(0, cut.Instance.ParentLoaderRuns);
+ Assert.AreEqual(0, cut.Instance.ChildLoaderRuns);
+ }
+
+ [TestMethod]
+ public async Task Routes_without_loaders_revalidate_as_a_noop()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/other");
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=other]"));
+
+ var brouter = Services.GetRequiredService();
+ await cut.InvokeAsync(() => brouter.RevalidateAsync().AsTask());
+
+ // Still rendered, nothing exploded, no loader ran.
+ Assert.IsNotNull(cut.Find("[data-testid=other]"));
+ Assert.AreEqual(0, cut.Instance.ParentLoaderRuns);
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/RouteDataCascadeTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/RouteDataCascadeTests.cs
new file mode 100644
index 0000000000..e97d3f49ff
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/RouteDataCascadeTests.cs
@@ -0,0 +1,76 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+public sealed record LoadedUser(string Name, int Age);
+
+[TestClass]
+public class RouteDataCascadeTests : BunitTestContext
+{
+ [TestMethod]
+ public void RouteData_and_RouteMeta_cascade_as_typed_wrappers_matched_by_type()
+ {
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/typed");
+
+ var cut = RenderComponent();
+
+ // TypedValueReader declares [CascadingParameter] without a Name, so this also proves
+ // the wrapper types are matched by type alone.
+ cut.WaitForAssertion(() =>
+ {
+ Assert.AreEqual("saleh", cut.Find("[data-testid=name]").TextContent);
+ Assert.AreEqual("42", cut.Find("[data-testid=age]").TextContent);
+ Assert.AreEqual("admin-area", cut.Find("[data-testid=meta]").TextContent);
+ });
+ }
+
+ [TestMethod]
+ public void Get_returns_value_when_type_matches()
+ {
+ var data = new BrouterRouteData(new LoadedUser("a", 1));
+
+ Assert.AreEqual(new LoadedUser("a", 1), data.Get());
+ Assert.IsTrue(data.HasValue);
+ }
+
+ [TestMethod]
+ public void Get_throws_with_distinct_messages_for_absent_and_mismatched_values()
+ {
+ var empty = BrouterRouteData.Empty;
+ var mismatched = new BrouterRouteData("a string");
+
+ var absentEx = Assert.ThrowsExactly(() => empty.Get());
+ var mismatchEx = Assert.ThrowsExactly(() => mismatched.Get());
+
+ StringAssert.Contains(absentEx.Message, "No route data value is present");
+ StringAssert.Contains(mismatchEx.Message, nameof(String));
+ StringAssert.Contains(mismatchEx.Message, nameof(LoadedUser));
+ }
+
+ [TestMethod]
+ public void TryGet_and_GetOrDefault_handle_absence_and_mismatch()
+ {
+ var data = new BrouterRouteData(new LoadedUser("a", 1));
+
+ Assert.IsTrue(data.TryGet(out var user));
+ Assert.AreEqual("a", user.Name);
+
+ Assert.IsFalse(data.TryGet(out _));
+ Assert.IsFalse(BrouterRouteData.Empty.TryGet(out _));
+
+ Assert.IsNull(data.GetOrDefault());
+ Assert.AreEqual("fallback", BrouterRouteData.Empty.GetOrDefault("fallback"));
+ }
+
+ [TestMethod]
+ public void Meta_wrapper_reports_absence_via_HasValue()
+ {
+ Assert.IsFalse(BrouterRouteMeta.Empty.HasValue);
+ Assert.IsNull(BrouterRouteMeta.Empty.Value);
+ Assert.IsTrue(new BrouterRouteMeta("m").HasValue);
+ }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/SampleDto.cs b/src/Brouter/Tests/Bit.Brouter.Tests/SampleDto.cs
new file mode 100644
index 0000000000..a7962d02d4
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/SampleDto.cs
@@ -0,0 +1,17 @@
+namespace Bit.Brouter.Tests;
+
+/// A simple serializable payload used to exercise prerender loader-state bridging.
+public sealed class SampleDto
+{
+ public string? Name { get; set; }
+ public int Count { get; set; }
+}
+
+///
+/// Source-generated JSON context used by the AOT-safe persistence tests
+/// (see BrouterOptions.LoaderStateTypeInfoResolver).
+///
+[System.Text.Json.Serialization.JsonSerializable(typeof(SampleDto))]
+public sealed partial class PersistenceTestJsonContext : System.Text.Json.Serialization.JsonSerializerContext
+{
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/SequentialLoadersHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/SequentialLoadersHost.razor
new file mode 100644
index 0000000000..9531149d06
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/SequentialLoadersHost.razor
@@ -0,0 +1,30 @@
+@using Bit.Brouter
+
+@* Nested chain with a loader at each level, default (sequential) mode: the parent's loader
+ must fully complete before the child's starts. *@
+
+
+
+
+
-
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/SpecificityHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/SpecificityHost.razor
index 0fd16d17df..db71387582 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/SpecificityHost.razor
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/SpecificityHost.razor
@@ -1,10 +1,10 @@
@* Hosts the wildcard + literal routes used to verify specificity-based winner selection. *@
-
+
star
-
-
+
+
about
-
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/StatefulCounter.razor b/src/Brouter/Tests/Bit.Brouter.Tests/StatefulCounter.razor
new file mode 100644
index 0000000000..26de394883
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/StatefulCounter.razor
@@ -0,0 +1,9 @@
+@* A component with internal state, for asserting whether navigation recreated it (count resets)
+ or kept it alive (count survives). *@
+
+
count:@_count
+
+
+@code {
+ private int _count;
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/SwapRouteHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/SwapRouteHost.razor
new file mode 100644
index 0000000000..6e24ca77f2
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/SwapRouteHost.razor
@@ -0,0 +1,22 @@
+@* Renders one of two same-template routes (or neither) depending on Show. Used by
+ AmbiguousRouteTests to verify that disposing a route frees its template for re-registration. *@
+@using Bit.Brouter
+
+
+ @if (Show == "a")
+ {
+
+
a
+
+ }
+ @if (Show == "b")
+ {
+
+
b
+
+ }
+
+
+@code {
+ [Parameter, EditorRequired] public string Show { get; set; } = default!;
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/TypedDataHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/TypedDataHost.razor
new file mode 100644
index 0000000000..bd1c6efaa6
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/TypedDataHost.razor
@@ -0,0 +1,14 @@
+@using Bit.Brouter
+
+
+
+
+
+
+
+
+
+@code {
+ private ValueTask Load(BrouterNavigationContext ctx) =>
+ ValueTask.FromResult(new LoadedUser("saleh", 42));
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/TypedValueReader.razor b/src/Brouter/Tests/Bit.Brouter.Tests/TypedValueReader.razor
new file mode 100644
index 0000000000..35c783c5d0
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/TypedValueReader.razor
@@ -0,0 +1,9 @@
+@* No Name on the cascading parameters: the wrapper types are unique, so type-based matching suffices. *@
+
@(Data?.Get().Name)
+
@(Data?.Get().Age)
+
@(Meta?.GetOrDefault())
+
+@code {
+ [CascadingParameter] public BrouterRouteData? Data { get; set; }
+ [CascadingParameter] public BrouterRouteMeta? Meta { get; set; }
+}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ValueReader.razor b/src/Brouter/Tests/Bit.Brouter.Tests/ValueReader.razor
index 300ebd49c6..b05bfb5253 100644
--- a/src/Brouter/Tests/Bit.Brouter.Tests/ValueReader.razor
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ValueReader.razor
@@ -1,5 +1,5 @@
-
@(Data?.ToString())
+
@(Data?.GetOrDefault())
@code {
- [CascadingParameter(Name = "RouteData")] public object? Data { get; set; }
+ [CascadingParameter] public BrouterRouteData? Data { get; set; }
}
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/ViewTransitionTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/ViewTransitionTests.cs
new file mode 100644
index 0000000000..d06c21c702
--- /dev/null
+++ b/src/Brouter/Tests/Bit.Brouter.Tests/ViewTransitionTests.cs
@@ -0,0 +1,94 @@
+using Bunit;
+using Bunit.TestDoubles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Bit.Brouter.Tests;
+
+[TestClass]
+public class ViewTransitionTests : BunitTestContext
+{
+ private BunitJSModuleInterop SetupModule(bool beginReturns)
+ {
+ var module = Context!.JSInterop.SetupModule("./_content/Bit.Brouter/bit-brouter.js");
+ module.Mode = JSRuntimeMode.Loose;
+ // beginViewTransition(navKind, useDefaultAnimations): match any argument values.
+ module.Setup("beginViewTransition", _ => true).SetResult(beginReturns);
+ module.SetupVoid("completeViewTransition").SetVoidResult();
+ return module;
+ }
+
+ [TestMethod]
+ public void Navigation_with_ViewTransitions_enabled_runs_the_begin_complete_handshake()
+ {
+ Services.Configure(o => o.ViewTransitions = true);
+ var module = SetupModule(beginReturns: true);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ // Begin fired before the render, complete after it landed.
+ Assert.IsTrue(module.Invocations.Count(i => i.Identifier == "beginViewTransition") >= 1);
+ Assert.IsTrue(module.Invocations.Count(i => i.Identifier == "completeViewTransition") >= 1);
+
+ // Begin carries the direction token (a programmatic Navigate is a push), the
+ // default-animations flag and the reduced-motion flag (both on by default), which
+ // drive the built-in direction-aware animations.
+ var begin = module.Invocations.First(i => i.Identifier == "beginViewTransition");
+ Assert.AreEqual("push", begin.Arguments[0]);
+ Assert.AreEqual(true, begin.Arguments[1]);
+ Assert.AreEqual(true, begin.Arguments[2]);
+ });
+ }
+
+ [TestMethod]
+ public void Unsupported_browser_skips_the_completion_round_trip()
+ {
+ Services.Configure(o => o.ViewTransitions = true);
+ var module = SetupModule(beginReturns: false);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ Assert.IsTrue(module.Invocations.Count(i => i.Identifier == "beginViewTransition") >= 1);
+ Assert.AreEqual(0, module.Invocations.Count(i => i.Identifier == "completeViewTransition"));
+ });
+ }
+
+ [TestMethod]
+ public void Disabled_by_default_no_transition_interop_happens()
+ {
+ var module = SetupModule(beginReturns: true);
+
+ var nav = Services.GetRequiredService();
+ nav.NavigateTo("http://localhost/a");
+ var cut = RenderComponent();
+ cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
+
+ var brouter = Services.GetRequiredService();
+ cut.InvokeAsync(() => brouter.Navigate("/b"));
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.IsNotNull(cut.Find("[data-testid=b]"));
+ Assert.AreEqual(0, module.Invocations.Count(i => i.Identifier == "beginViewTransition"));
+ Assert.AreEqual(0, module.Invocations.Count(i => i.Identifier == "completeViewTransition"));
+ });
+ }
+}