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 values) + { + ArgumentException.ThrowIfNullOrEmpty(key); + ArgumentNullException.ThrowIfNull(values); + + List formatted = []; + foreach (var value in values) + { + if (value is null) continue; + formatted.Add(BrouterService.FormatRouteValue(value)); + } + if (formatted.Count == 0) return Remove(key); + + if (_values.ContainsKey(key) is false) _order.Add(key); + _values[key] = formatted; + return this; + } + + /// Removes entirely. + public BrouterQueryBuilder Remove(string key) + { + ArgumentException.ThrowIfNullOrEmpty(key); + if (_values.Remove(key)) + { + _order.RemoveAll(k => string.Equals(k, key, StringComparison.OrdinalIgnoreCase)); + } + return this; + } + + /// Removes every parameter. + public BrouterQueryBuilder Clear() + { + _order.Clear(); + _values.Clear(); + return this; + } + + /// The current single value for (first when multi-valued), or null. + public string? Get(string key) => + _values.TryGetValue(key, out var list) && list.Count > 0 ? list[0] : null; + + /// Whether is present. + public bool Contains(string key) => _values.ContainsKey(key); + + /// Serializes back to a query string including the leading '?', or an empty string when empty. + public string ToQueryString() + { + if (_order.Count == 0) return string.Empty; + + var sb = new StringBuilder(); + foreach (var key in _order) + { + if (_values.TryGetValue(key, out var values) is false) continue; + foreach (var value in values) + { + sb.Append(sb.Length == 0 ? '?' : '&'); + sb.Append(Uri.EscapeDataString(key)); + if (value.Length > 0) + { + sb.Append('=').Append(Uri.EscapeDataString(value)); + } + } + } + return sb.ToString(); + } +} diff --git a/src/Brouter/Bit.Brouter/BrouterRelativeUrl.cs b/src/Brouter/Bit.Brouter/BrouterRelativeUrl.cs new file mode 100644 index 0000000000..99f6869559 --- /dev/null +++ b/src/Brouter/Bit.Brouter/BrouterRelativeUrl.cs @@ -0,0 +1,60 @@ +namespace Bit.Brouter; + +/// +/// Resolves route-relative URLs (., .., ./x, ../x) against a current +/// path using segment math, React Router style: . is the current path, each .. drops +/// one trailing segment. Used by , +/// and . +/// +internal static class BrouterRelativeUrl +{ + /// + /// True when is a route-relative reference: ".", "..", a + /// path starting with "./" / "../", or one of those dot forms followed directly by + /// a query or hash (".?tab=2", "..#top") - preserves that + /// suffix. Deliberately narrow - a bare segment like "sibling" (or a dotted name like + /// ".well-known") is NOT treated as relative and keeps its historical base-relative + /// meaning through NavigationManager, so introducing relative resolution can't silently + /// change the destination of existing URLs. + /// + internal static bool IsRelative(string url) + { + if (string.IsNullOrEmpty(url) || url[0] != '.') return false; + if (url.Length == 1 || url[1] is '/' or '?' or '#') return true; // "." or "./..." or ".?..."/".#..." + if (url[1] == '.') return url.Length == 2 || url[2] is '/' or '?' or '#'; // ".." or "../..." or "..?..."/"..#..." + return false; + } + + /// + /// Resolves (which must satisfy ) against + /// . Segment math, not RFC 3986 directory semantics: + /// from /users/42, ./edit yields /users/42/edit and ../7 yields + /// /users/7. .. above the root clamps at the root (mirroring how browsers treat + /// excess parent references). Any query/hash on is preserved. + /// + internal static string Resolve(string currentPath, string url) + { + // Split any query/hash off first; only the path part participates in segment math. + var suffixStart = url.AsSpan().IndexOfAny('?', '#'); + var relPath = suffixStart < 0 ? url : url[..suffixStart]; + var suffix = suffixStart < 0 ? string.Empty : url[suffixStart..]; + + var segments = new List(currentPath.Split('/', StringSplitOptions.RemoveEmptyEntries)); + foreach (var segment in relPath.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + if (segment == ".") continue; + if (segment == "..") + { + if (segments.Count > 0) segments.RemoveAt(segments.Count - 1); + continue; + } + segments.Add(segment); + } + + return segments.Count == 0 ? "/" + suffix : "/" + string.Join('/', segments) + suffix; + } + + /// Resolves against when it is relative; returns it unchanged otherwise. + internal static string ResolveIfRelative(string currentPath, string url) => + IsRelative(url) ? Resolve(currentPath, url) : url; +} diff --git a/src/Brouter/Bit.Brouter/BrouterRoute.cs b/src/Brouter/Bit.Brouter/BrouterRoute.cs deleted file mode 100644 index 8d3e8299c9..0000000000 --- a/src/Brouter/Bit.Brouter/BrouterRoute.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Rendering; - -namespace Bit.Brouter; - -/// -/// Declares a single route inside a . -/// -public class BrouterRoute : 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; - - /// 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 data loader. Runs after the route matches and guards pass, before render. - /// The result is exposed via the cascading RouteData value. - /// Inspired by React Router v6's loader and Angular's Resolve. - /// - [Parameter] public Func>? Loader { get; set; } - - /// Optional metadata. Exposed via the cascading RouteMeta value. - [Parameter] public object? Meta { 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 BrouterRoute? Parent { get; set; } - [CascadingParameter(Name = "RouteParameters")] internal BrouterRouteParameters? InheritedParameters { get; set; } - - - internal string FullTemplate { get; private set; } = string.Empty; - - - private readonly List _children = []; - internal void AddChild(BrouterRoute route) => _children.Add(route); - internal void RemoveChild(BrouterRoute route) => _children.Remove(route); - - internal BrouterOutlet? Outlet { get; set; } - - internal BrouterRouteTemplate? RouteTemplate { get; private 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; } - - 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 (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."); - - // 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('/')}"; - } - - RouteTemplate = BrouterTemplateParser.ParseTemplate(FullTemplate); - - // 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; - - var depth = 0; - for (var p = Parent; p is not null; p = p.Parent) depth++; - Depth = depth; - - IsIndex = Parent is not null && string.IsNullOrEmpty(Path.Trim('/')); - - _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; } - - - internal bool Matched { get; set; } - - protected override void BuildRenderTree(RenderTreeBuilder builder) - { - base.BuildRenderTree(builder); - _renderer?.BuildRenderTree(builder, Matched); - } - - internal void SetMatched() - { - Matched = true; - - StateHasChanged(); - - Parent?.SetMatched(); - } - - 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); - } - - private bool _disposed; -} diff --git a/src/Brouter/Bit.Brouter/BrouterRouteConstraint.cs b/src/Brouter/Bit.Brouter/BrouterRouteConstraint.cs index 897f16718b..1451d4c85e 100644 --- a/src/Brouter/Bit.Brouter/BrouterRouteConstraint.cs +++ b/src/Brouter/Bit.Brouter/BrouterRouteConstraint.cs @@ -2,7 +2,7 @@ namespace Bit.Brouter; /// /// Base type for parameter constraints. Custom constraints can be registered via -/// . +/// (on ). /// /// /// A single instance is registered per constraint name and @@ -15,12 +15,16 @@ public abstract class BrouterRouteConstraint public abstract bool TryMatch(string pathSegment, out object? convertedValue); - internal static BrouterRouteConstraint Resolve(string template, string segment, string constraint) + internal static BrouterRouteConstraint Resolve(string template, string segment, string constraint, BrouterConstraintRegistry? registry) { if (string.IsNullOrEmpty(constraint)) throw new ArgumentException($"Malformed segment '{segment}' in route '{template}' contains an empty constraint."); - return BrouterConstraints.Create(constraint) + // Prefer the per-container registry (custom constraints + built-ins). When no registry is + // threaded (e.g. a direct ParseTemplate call in tests), resolve against the shared built-ins. + var resolved = registry is not null ? registry.Create(constraint) : BrouterConstraintRegistry.CreateBuiltIn(constraint); + + return resolved ?? throw new ArgumentException($"Unsupported constraint '{constraint}' in route '{template}'."); } } diff --git a/src/Brouter/Bit.Brouter/BrouterRouteData.cs b/src/Brouter/Bit.Brouter/BrouterRouteData.cs new file mode 100644 index 0000000000..5df4625b36 --- /dev/null +++ b/src/Brouter/Bit.Brouter/BrouterRouteData.cs @@ -0,0 +1,91 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Bit.Brouter; + +/// +/// Base of the typed wrappers Brouter cascades to matched route content +/// ( and ). Wraps the raw +/// payload with type-safe accessors so consumers don't cast by hand, +/// mirroring the Get/TryGet/GetOrDefault surface of +/// . +/// +public abstract class BrouterRouteValue +{ + private protected BrouterRouteValue(object? value) => Value = value; + + /// The raw underlying value, or null when nothing was supplied. + public object? Value { get; } + + /// True when an underlying value is present (non-null). + public bool HasValue => Value is not null; + + /// Returns the value as . + /// + /// Thrown when no value is present or the value is not a . Use + /// or when absence or a + /// different type is expected. + /// + public T Get() + { + if (Value is T t) return t; + + // Distinguish "nothing was supplied" from "supplied, but of another type": the first + // usually means the consumer rendered outside a matched route (or the route has no + // loader/meta), the second is a plain type mismatch at the call site. + throw new InvalidOperationException(Value is null + ? $"No {Kind} value is present. Use TryGet/GetOrDefault when absence is expected." + : $"The {Kind} value is of type {Value.GetType().Name} and cannot be read as {typeof(T).Name}."); + } + + /// Returns the value as , or when absent or of another type. + public T? GetOrDefault(T? defaultValue = default) => Value is T t ? t : defaultValue; + + /// Tries to read the value as . + public bool TryGet([MaybeNullWhen(false)] out T value) + { + if (Value is T t) + { + value = t; + return true; + } + value = default; + return false; + } + + // Human-readable kind ("route data" / "route meta") used in Get error messages. + private protected abstract string Kind { get; } +} + +/// +/// The typed cascading wrapper around a matched route's result. +/// Consume it with [CascadingParameter] BrouterRouteData? Data - the cascade is unnamed +/// and matched by this unique type, so no Name is used. The wrapper instance is always +/// non-null under a matched route; is null when the route +/// has no loader or the loader returned null. +/// +public sealed class BrouterRouteData : BrouterRouteValue +{ + /// A data instance carrying no value. + public static readonly BrouterRouteData Empty = new(null); + + internal BrouterRouteData(object? value) : base(value) { } + + private protected override string Kind => "route data"; +} + +/// +/// The typed cascading wrapper around a matched route's value. +/// Consume it with [CascadingParameter] BrouterRouteMeta? Meta - the cascade is unnamed +/// and matched by this unique type, so no Name is used. The wrapper instance is always +/// non-null under a matched route; is null when the route +/// declares no meta. +/// +public sealed class BrouterRouteMeta : BrouterRouteValue +{ + /// A meta instance carrying no value. + public static readonly BrouterRouteMeta Empty = new(null); + + internal BrouterRouteMeta(object? value) : base(value) { } + + private protected override string Kind => "route meta"; +} diff --git a/src/Brouter/Bit.Brouter/BrouterRouteRenderer.cs b/src/Brouter/Bit.Brouter/BrouterRouteRenderer.cs index bc1ea4b1db..b239ab8696 100644 --- a/src/Brouter/Bit.Brouter/BrouterRouteRenderer.cs +++ b/src/Brouter/Bit.Brouter/BrouterRouteRenderer.cs @@ -5,7 +5,7 @@ namespace Bit.Brouter; internal class BrouterRouteRenderer { - private readonly BrouterRoute _route; + private readonly Broute _route; // Cache the last merged BrouterRouteParameters and the (inherited, local) reference pair // it was derived from. RenderRoute runs on every render of every route in the matched @@ -17,33 +17,119 @@ internal class BrouterRouteRenderer private BrouterRouteParameters? _cachedInheritedRef; private IReadOnlyDictionary? _cachedLocalRef; - public BrouterRouteRenderer(BrouterRoute route) + // Same idea for the RouteData / RouteMeta wrappers: rebuild only when the underlying + // reference changes. Reusing the wrapper instance also keeps CascadingValue's change + // detection quiet on renders where the payload didn't change (a fresh wrapper every + // render would re-notify every subscriber), matching the old raw-object? behavior + // where an unchanged reference meant "no cascade update". + private BrouterRouteData? _cachedRouteData; + private object? _cachedLoadedDataRef; + private BrouterRouteMeta? _cachedRouteMeta; + private object? _cachedMetaRef; + + // Keep-alive lifecycle context (see BrouterKeepAliveContext) for the singleton mode + // (EffectiveKeepAliveMax <= 1). A fresh instance is handed out only when the active/hidden state + // flips, so consumers reading IsActive in OnParametersSet are notified on every transition while + // unchanged renders stay allocation-free and quiet. + private bool _keepAliveActive; + private BrouterKeepAliveContext? _keepAliveContext; + + // Set by DropKeptContent (IBrouter.ClearKeepAlive) to stop rendering this route's kept-but-hidden + // content, so its component is disposed and its retained state released. Reset the moment the + // route matches again, so a later visit rebuilds it fresh. + private bool _keptDropped; + + // Per-parameter retention (EffectiveKeepAliveMax > 1) for the inline (non-outlet) render path: + // one entry per recently-visited parameter set, LRU-ordered with the most recently active at the + // end. Each entry owns a keyed subtree whose parameters/data are frozen while hidden, so a + // hidden /item/1 instance keeps seeing id=1 while the route's current match is /item/2. + private readonly List _keptEntries = []; + + private sealed class KeptEntry + { + public string Key { get; } + public BrouterRouteParameters Parameters { get; set; } = BrouterRouteParameters.Empty; + public BrouterRouteData Data { get; set; } = BrouterRouteData.Empty; + + // Per-entry lifecycle context, minted only on an active/hidden flip (same contract as the + // renderer-level singleton context above). + private bool _active; + private BrouterKeepAliveContext? _context; + + public KeptEntry(string key) => Key = key; + + public BrouterKeepAliveContext GetKeepAliveContext(bool active) + { + if (_context is null || _active != active) + { + _context = new BrouterKeepAliveContext(active); + _active = active; + } + return _context; + } + } + + public BrouterRouteRenderer(Broute route) { _route = route; } + // Drops this route's retained (hidden) keep-alive content on the next render, keeping only the + // currently active instance (when the route is matched). Backs IBrouter.ClearKeepAlive. + public void DropKeptContent(bool routeIsMatched) + { + // Per-parameter mode: the active entry (list tail, see RenderKeptEntries' LRU ordering) + // survives when the route is matched; every hidden sibling is dropped so its subtree is + // disposed on the next render. + if (_keptEntries.Count > 0) + { + var keep = routeIsMatched ? _keptEntries[^1] : null; + _keptEntries.Clear(); + if (keep is not null) _keptEntries.Add(keep); + } + + // Singleton mode: only kept-but-hidden content is dropped; an active route stays rendered. + if (routeIsMatched is false) _keptDropped = true; + } + + private BrouterKeepAliveContext GetKeepAliveContext(bool active) + { + if (_keepAliveContext is null || _keepAliveActive != active) + { + _keepAliveContext = new BrouterKeepAliveContext(active); + _keepAliveActive = active; + } + return _keepAliveContext; + } + public void BuildRenderTree(RenderTreeBuilder builder, bool matched) { - builder.OpenComponent>(0); + // A fresh match clears any prior "dropped" state so the route renders again. + if (matched) _keptDropped = false; + + builder.OpenComponent>(0); builder.AddAttribute(1, "Name", "ParentRoute"); builder.AddAttribute(2, "Value", _route); builder.AddAttribute(3, "ChildContent", (RenderFragment)(b => { b.AddContent(0, _route.ChildContent); - if (matched) + // A KeepAlive route that has been shown at least once keeps rendering while unmatched - + // hidden - so its component state survives until it matches again (unless ClearKeepAlive + // dropped it, in which case it re-renders only once it matches again). + if (matched || (_route.KeepAlive && _route.HasEverMatched && _keptDropped is false)) { // RenderRoute restarts its own sequence numbers from 0; wrap it in a region // so its frames live in an independent sequence-number space and don't collide // with the AddContent above. b.OpenRegion(1); - RenderRoute(b); + RenderRoute(b, matched); b.CloseRegion(); } })); builder.CloseComponent(); } - private void RenderRoute(RenderTreeBuilder builder) + private void RenderRoute(RenderTreeBuilder builder, bool matched) { var inherited = _route.InheritedParameters; var local = _route.Parameters; @@ -71,38 +157,99 @@ private void RenderRoute(RenderTreeBuilder builder) _cachedLocalRef = local; } + // Typed wrappers instead of raw object? cascades: a distinct wrapper type per cascade + // means consumers get compile-time-safe access (Get/TryGet) and match by type + // alone. The cascades are deliberately unnamed - a named CascadingValue only supplies + // consumers that request that exact name, so naming them would break plain + // [CascadingParameter] BrouterRouteData properties. The unique wrapper types make a + // name redundant for disambiguation. + var loadedData = _route.LoadedData; + if (_cachedRouteData is null || ReferenceEquals(_cachedLoadedDataRef, loadedData) is false) + { + _cachedRouteData = loadedData is null ? BrouterRouteData.Empty : new BrouterRouteData(loadedData); + _cachedLoadedDataRef = loadedData; + } + + var meta = _route.Meta; + if (_cachedRouteMeta is null || ReferenceEquals(_cachedMetaRef, meta) is false) + { + _cachedRouteMeta = meta is null ? BrouterRouteMeta.Empty : new BrouterRouteMeta(meta); + _cachedMetaRef = meta; + } + var routeData = _cachedRouteData; + var routeMeta = _cachedRouteMeta; + builder.OpenComponent>(0); builder.AddAttribute(1, "Name", "RouteParameters"); builder.AddAttribute(2, "Value", routeParams); builder.AddAttribute(3, "IsFixed", false); builder.AddAttribute(4, "ChildContent", (RenderFragment)(b1 => { - b1.OpenComponent>(0); - b1.AddAttribute(1, "Name", "RouteData"); - b1.AddAttribute(2, "Value", _route.LoadedData); - b1.AddAttribute(3, "ChildContent", (RenderFragment)(b2 => + b1.OpenComponent>(0); + b1.AddAttribute(1, "Value", routeData); + b1.AddAttribute(2, "ChildContent", (RenderFragment)(b2 => { - b2.OpenComponent>(0); - b2.AddAttribute(1, "Name", "RouteMeta"); - b2.AddAttribute(2, "Value", _route.Meta); - b2.AddAttribute(3, "ChildContent", (RenderFragment)(b3 => + b2.OpenComponent>(0); + b2.AddAttribute(1, "Value", routeMeta); + b2.AddAttribute(2, "ChildContent", (RenderFragment)(b3 => { - if (_route.Parent?.Outlet is null) + // Resolve the outlet host: normally the immediate parent, but pathless Group + // ancestors are invisible to layout just as they are to the URL - a group that + // hosts no outlets of its own passes its children through to ITS parent's + // outlets. The walk stops at the first ancestor with outlets (a group CAN host + // its own via a layout Content) or at the first non-group ancestor either way. + Broute? outletHost = null; + for (var p = _route.Parent; p is not null; p = p.Parent) { - if (_route.Content is not null) + if (p.Outlets.Count > 0) { - b3.AddContent(0, _route.Content(routeParams)); + outletHost = p; + break; } - else if (_route.Component is not null) + if (p.Group is false) break; // non-group ancestor without outlets: render inline + } + + // Hand the matched child to the host's outlets (the primary outlet renders its + // content/error UI, named outlets its BrouterView fragments). Only a *matched* + // route may claim the outlets - a hidden KeepAlive pass must not hijack them. + if (matched && outletHost is not null) + { + outletHost.SetOutletChild(_route, routeParams); + } + + // Without a primary outlet on the host, the content renders inline right here. + if (outletHost is null || outletHost.HasPrimaryOutlet is false) + { + if (_route.KeepAlive && _route.EffectiveKeepAliveMax > 1) + { + // Per-parameter retention: one keyed, hidden-unless-active subtree per + // recently-visited parameter set, LRU-evicted over the route's budget. + RenderKeptEntries(b3, matched, routeParams, routeData); + } + else if (_route.KeepAlive) { - b3.OpenComponent(0, _route.Component); - ApplyTypedParameters(b3, _route.Component, routeParams, _route.Brouter?.CurrentLocation); + // Singleton retention: one instance that re-binds across parameter + // changes. The stable wrapper element is what preserves the component + // subtree across matched <-> hidden flips; only its hidden attribute + // toggles. + var keepAlive = GetKeepAliveContext(matched); + b3.OpenElement(0, "div"); + if (matched is false) b3.AddAttribute(1, "hidden", true); + b3.OpenRegion(2); + // Cascade the activate/deactivate signal to the kept content so it can + // pause/resume work while hidden (see BrouterKeepAliveContext). + b3.OpenComponent>(0); + b3.AddAttribute(1, "Value", keepAlive); + b3.AddAttribute(2, "IsFixed", false); + b3.AddAttribute(3, "ChildContent", (RenderFragment)(bk => EmitContent(bk, routeParams))); b3.CloseComponent(); + b3.CloseRegion(); + b3.CloseElement(); + } + else + { + EmitContent(b3, routeParams); } - } - else - { - _route.Parent.Outlet.Render(_route, routeParams); } })); b2.CloseComponent(); @@ -112,16 +259,143 @@ private void RenderRoute(RenderTreeBuilder builder) builder.CloseComponent(); } - internal static void ApplyTypedParameters(RenderTreeBuilder builder, [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] Type componentType, BrouterRouteParameters parameters, BrouterLocation? location) + /// + /// Per-parameter keep-alive rendering for the inline (non-outlet) path. Maintains the LRU entry + /// list (find-or-create the active entry by the current match's parameter key, move it to the + /// tail, evict the head beyond the budget) and renders every entry inside a keyed wrapper that + /// is hidden unless active - mirroring BrouterOutlet's kept-children region. + /// + private void RenderKeptEntries(RenderTreeBuilder b3, bool matched, BrouterRouteParameters routeParams, BrouterRouteData? routeData) + { + KeptEntry? active = null; + if (matched) + { + var key = _route.ComputeKeepAliveKey(); + active = _keptEntries.Find(e => string.Equals(e.Key, key, StringComparison.Ordinal)); + if (active is null) + { + active = new KeptEntry(key); + _keptEntries.Add(active); + } + else if (ReferenceEquals(_keptEntries[^1], active) is false) + { + // LRU order: the most recently active entry lives at the tail. + _keptEntries.Remove(active); + _keptEntries.Add(active); + } + + // Only the active entry re-binds to the current match; hidden entries keep the + // parameters/data they were deactivated with. + active.Parameters = routeParams; + active.Data = routeData ?? BrouterRouteData.Empty; + + // Evict beyond the budget: the head is always the least-recently-used hidden entry + // (the active one was just moved to the tail). + var max = _route.EffectiveKeepAliveMax; + while (_keptEntries.Count > max) + { + _keptEntries.RemoveAt(0); + } + } + + // Constant sequence numbers per iteration (the canonical keyed-list pattern): entries move + // positions on every LRU reorder, 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. The + // key alone disambiguates siblings. + foreach (var entry in _keptEntries) + { + var isActive = ReferenceEquals(entry, active); + b3.OpenElement(0, "div"); + b3.SetKey(entry); + if (isActive is false) b3.AddAttribute(1, "hidden", true); + b3.OpenRegion(2); + RenderKeptEntry(b3, entry, isActive); + b3.CloseRegion(); + b3.CloseElement(); + } + } + + // One kept entry's subtree: shadows the outer RouteParameters/RouteData cascades with the + // entry's own (frozen-while-hidden) values, then cascades the activate/deactivate signal, then + // emits the route content bound to the entry's parameters. + private void RenderKeptEntry(RenderTreeBuilder b, KeptEntry entry, bool isActive) + { + var context = entry.GetKeepAliveContext(isActive); + var parameters = entry.Parameters; + var data = entry.Data; + + b.OpenComponent>(0); + b.AddAttribute(1, "Name", "RouteParameters"); + b.AddAttribute(2, "Value", parameters); + b.AddAttribute(3, "IsFixed", false); + b.AddAttribute(4, "ChildContent", (RenderFragment)(b1 => + { + b1.OpenComponent>(0); + b1.AddAttribute(1, "Value", data); + b1.AddAttribute(2, "ChildContent", (RenderFragment)(b2 => + { + b2.OpenComponent>(0); + b2.AddAttribute(1, "Value", context); + b2.AddAttribute(2, "IsFixed", false); + b2.AddAttribute(3, "ChildContent", (RenderFragment)(bk => EmitContent(bk, parameters))); + b2.CloseComponent(); + })); + b1.CloseComponent(); + })); + b.CloseComponent(); + } + + // The route's error-boundary/content/component trio for inline (non-outlet) rendering. + // Same sequence number across the mutually-exclusive branches is fine - only one renders + // per pass and they diff cleanly across renders. + private void EmitContent(RenderTreeBuilder b3, BrouterRouteParameters routeParams) + { + // Active error boundary: the error UI replaces this route's content while the + // surrounding cascades (parameters/data/meta) stay available to the fragment. + if (_route.CurrentError is not null && _route.ErrorContent is not null) + { + b3.AddContent(0, _route.ErrorContent(_route.CurrentError)); + } + else if (_route.Content is not null) + { + b3.AddContent(0, _route.Content(routeParams)); + } + else if (_route.Component is not null) + { + b3.OpenComponent(0, _route.Component); + ApplyTypedParameters(b3, _route.Component, routeParams, _route.Brouter?.CurrentLocation, + _route.BindComponentParametersByName ? _route.TemplateParameterNames : null); + b3.CloseComponent(); + } + } + + internal static void ApplyTypedParameters(RenderTreeBuilder builder, [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] Type componentType, BrouterRouteParameters parameters, BrouterLocation? location, IReadOnlySet? conventionalTemplateParameters = null) { // Reflect once per type. Simple, correct, allocates only on first hit per type. // Trimming: Component is annotated DynamicallyAccessedMemberTypes.All so its members are preserved. - var bindings = BrouterTypedParameterCache.GetBindings(componentType); + // + // Two binding modes: + // - Default (conventionalTemplateParameters is null): bind only [BrouterParameter]/[BrouterQuery] + // annotated properties. This is the original, opt-in Brouter model. + // - Conventional (BindComponentParametersByName / attribute-discovered @page routes): additionally + // bind plain [Parameter] properties by name and [SupplyParameterFromQuery] properties from the + // query, Blazor-style. Plain [Parameter] properties that don't correspond to a route parameter + // in this route's template are skipped so unrelated component parameters aren't clobbered. + var conventional = conventionalTemplateParameters is not null; + var bindings = conventional + ? BrouterTypedParameterCache.GetConventionalBindings(componentType) + : BrouterTypedParameterCache.GetBindings(componentType); // Sequence numbers for dynamic parameter attributes start after the OpenComponent (0). // These are stable per render because the same bindings are iterated in the same order. var seq = 1; foreach (var b in bindings) { + // In conventional mode, a non-query binding whose name isn't one of this route's template + // parameters is a plain component input, not a route value: leave it untouched. (The skip set + // is deterministic for a given type+template, so sequence numbers stay stable across renders.) + if (conventional && b.IsQuery is false && conventionalTemplateParameters!.Contains(b.ParameterName) is false) + continue; + // Always emit an attribute frame per binding, even when the binding is missing or // unconvertible. Component instances are reused across navigations that match the // same Component (e.g. /profile/saleh -> /profile), so silently skipping a frame @@ -282,6 +556,11 @@ internal static class BrouterTypedParameterCache // many such components are mounted at once (e.g. a list page with many cards). private static readonly System.Collections.Concurrent.ConcurrentDictionary _cache = new(); + // Separate cache for the conventional (by-name) binding set used by attribute-discovered / @page + // routes. Kept apart from _cache because the two produce different binding sets for the same type + // (conventional covers every [Parameter] property; the default covers only annotated ones). + private static readonly System.Collections.Concurrent.ConcurrentDictionary _conventionalCache = new(); + public static BrouterParameterBinding[] GetBindings([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] Type type) { // Fast path: hit the cached value without going through the factory delegate. @@ -348,6 +627,63 @@ private static BrouterParameterBinding[] BuildBindings([System.Diagnostics.CodeA return bindings.ToArray(); } + + /// + /// Builds the binding set for conventional (Blazor-style) route components - those rendered by an + /// attribute-discovered route or with set. Every + /// public [Parameter] property is considered: query-supplied ones ([SupplyParameterFromQuery] + /// or [BrouterQuery]) become query bindings, the rest become route-parameter bindings keyed by + /// property name (honoring a [BrouterParameter(Name = ...)] override). The caller filters the + /// route bindings down to the parameters actually present in the route template. + /// + public static BrouterParameterBinding[] GetConventionalBindings([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] Type type) + { + if (_conventionalCache.TryGetValue(type, out var cached)) return cached; + + var bindings = BuildConventionalBindings(type); + _conventionalCache.TryAdd(type, bindings); + return _conventionalCache.TryGetValue(type, out var stored) ? stored : bindings; + } + + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2067", + Justification = "type flows from GetConventionalBindings whose parameter is annotated with " + + "DynamicallyAccessedMemberTypes.PublicProperties; the factory only reads public properties.")] + private static BrouterParameterBinding[] BuildConventionalBindings([System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] Type type) + { + var bindings = new List(); + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Only Blazor component parameters participate. [CascadingParameter] properties are driven by + // the framework, not by route values, so they're intentionally excluded. + if (prop.GetCustomAttribute() is null) continue; + if (prop.SetMethod is null || prop.SetMethod.IsPublic is false) continue; + + var brouterParam = prop.GetCustomAttribute(); + var brouterQuery = prop.GetCustomAttribute(); + if (brouterParam is not null && brouterQuery is not null) + throw new InvalidOperationException( + $"Property '{type.FullName}.{prop.Name}' is annotated with both " + + $"[{nameof(BrouterParameterAttribute)}] and [{nameof(BrouterQueryAttribute)}]. " + + "Pick exactly one: a property can bind to either a route parameter or a query string value, not both."); + + var supplyFromQuery = prop.GetCustomAttribute(); + + if (brouterQuery is not null) + { + bindings.Add(new BrouterParameterBinding(prop.Name, brouterQuery.Name ?? prop.Name, prop.PropertyType, IsQuery: true)); + } + else if (supplyFromQuery is not null) + { + bindings.Add(new BrouterParameterBinding(prop.Name, supplyFromQuery.Name ?? prop.Name, prop.PropertyType, IsQuery: true)); + } + else + { + bindings.Add(new BrouterParameterBinding(prop.Name, brouterParam?.Name ?? prop.Name, prop.PropertyType, IsQuery: false)); + } + } + + return bindings.ToArray(); + } } internal readonly record struct BrouterParameterBinding(string PropertyName, string ParameterName, Type PropertyType, bool IsQuery); diff --git a/src/Brouter/Bit.Brouter/BrouterScrollPositionStorage.cs b/src/Brouter/Bit.Brouter/BrouterScrollPositionStorage.cs new file mode 100644 index 0000000000..aaf64700cd --- /dev/null +++ b/src/Brouter/Bit.Brouter/BrouterScrollPositionStorage.cs @@ -0,0 +1,31 @@ +namespace Bit.Brouter; + +/// +/// Where saved scroll positions are kept when is +/// enabled. Only affects how long the positions live; the restore behavior itself is identical. +/// +public enum BrouterScrollPositionStorage +{ + /// + /// Keep positions in memory only. They are lost on a full page reload (or when navigating away to + /// another origin and back). Cheapest and leaks nothing to disk. This is the default. + /// + Memory = 0, + + /// + /// Persist positions in the browser's sessionStorage: they survive a full reload within the + /// same tab and are cleared automatically when the tab is closed, and are never shared with other + /// tabs. This is the recommended choice for scroll restoration (it mirrors how React Router's + /// ScrollRestoration stores positions). + /// + SessionStorage = 1, + + /// + /// Persist positions in the browser's localStorage: they survive reloads and browser + /// restarts and are shared across every tab of the same origin. Because tabs share one store, tabs + /// can overwrite each other's saved positions for the same URL, and positions are never cleared + /// automatically. Prefer unless you specifically need cross-restart + /// persistence. + /// + LocalStorage = 2 +} diff --git a/src/Brouter/Bit.Brouter/BrouterService.cs b/src/Brouter/Bit.Brouter/BrouterService.cs index 1567827d09..e024d95086 100644 --- a/src/Brouter/Bit.Brouter/BrouterService.cs +++ b/src/Brouter/Bit.Brouter/BrouterService.cs @@ -2,19 +2,33 @@ using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.JSInterop; namespace Bit.Brouter; -internal sealed class BrouterService : IBrouter +internal sealed class BrouterService : IBrouter, IAsyncDisposable { private readonly BrouterOptions _options; private readonly IJSRuntime _js; + private readonly ILogger _logger; private Brouter? _activeBrouter; private NavigationManager? _navigationManager; - public BrouterService(IOptions options, IJSRuntime js) + // Lazily-imported bit-brouter.js module, used for the post-navigation DOM effects + // (fragment/top scroll, focus) and shared with every BrouterLink that needs JS wiring + // (Replace links). Imported on first use and reused for the scope's lifetime; disposed + // in DisposeAsync when DI tears the scoped service down. The pending import Task is + // cached (rather than the resolved reference) so concurrent first calls - e.g. a page + // full of Replace links wiring up in the same OnAfterRender pass - coalesce into a + // single interop round-trip instead of racing N imports. + private Task? _moduleTask; + + // The logger is optional (with a null-object fallback) so resolving the service never fails + // in a container that has no logging registered. + public BrouterService(IOptions options, IJSRuntime js, ILogger? logger = null) { // Resolve once: BrouterService is scoped, BrouterOptions is registered as a singleton // via AddOptions, so the resolved value is stable for the lifetime of the scope. @@ -24,10 +38,24 @@ public BrouterService(IOptions options, IJSRuntime js) // and re-evaluating every active link, which isn't a supported scenario right now. _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _js = js; + _logger = logger ?? NullLogger.Instance; } internal BrouterOptions Options => _options; + // The scoped stale-while-revalidate store for loader results (see Broute.StaleTime and link + // preloading). Lives on the service rather than the Brouter component so it survives router + // re-mounts within the scope. + internal BrouterLoaderCache LoaderCache { get; } = new(); + + public void ClearLoaderCache() => LoaderCache.Clear(); + + public void ClearKeepAlive() + { + EnsureMounted(); + _activeBrouter!.ClearKeepAlive(); + } + internal void Attach(Brouter brouter, NavigationManager navManager) { // Only one may be mounted per scope. Two competing instances would race @@ -59,10 +87,58 @@ internal void Detach(Brouter brouter) public BrouterLocation Location => _activeBrouter?.CurrentLocation ?? BrouterLocation.Empty; - public void Navigate(string url, bool replace = false, bool forceLoad = false) + public void Navigate(string url, bool replace = false, bool forceLoad = false, string? historyState = null) + { + EnsureMounted(); + // Route-relative URLs ("./edit", "../sibling") resolve against the current location's + // path via segment math. Anything else (including bare "sibling") flows through to + // NavigationManager unchanged and keeps its base-relative meaning. + url = BrouterRelativeUrl.ResolveIfRelative(Location.Path, url); + NavigateCore(url, replace, forceLoad, historyState); + } + + public ValueTask NavigateAsync(string url, bool replace = false, string? historyState = null) { EnsureMounted(); - _navigationManager!.NavigateTo(url, forceLoad: forceLoad, replace: replace); + url = BrouterRelativeUrl.ResolveIfRelative(Location.Path, url); + + // Register the awaiter under the exact absolute URI the pipeline will observe as its + // target, BEFORE triggering the navigation (on some hosts NavigateTo runs the changing + // handler synchronously, so registering afterwards would miss the whole navigation). + var absoluteUri = _navigationManager!.ToAbsoluteUri(url).ToString(); + var outcome = _activeBrouter!.RegisterNavigationOutcome(absoluteUri); + + NavigateCore(url, replace, forceLoad: false, historyState); + return new ValueTask(outcome); + } + + // Shared trigger for Navigate/NavigateAsync: stamps the navigation type and dispatches to the + // right NavigateTo overload. `url` is already relative-resolved by the caller. + private void NavigateCore(string url, bool replace, bool forceLoad, string? historyState) + { + // Tell the pipeline this navigation is a push/replace before triggering it, so guards, loaders + // and hooks report the right BrouterNavigationType. Skipped for forceLoad: it's a full-page + // reload, so no SPA pipeline runs and there is nothing to classify. Back/Forward don't come + // through here (they use history.go), so they correctly fall through to Pop detection. + if (forceLoad is false) + _activeBrouter!.SetPendingNavigationType(replace ? BrouterNavigationType.Replace : BrouterNavigationType.Push); + + if (historyState is null) + { + _navigationManager!.NavigateTo(url, forceLoad: forceLoad, replace: replace); + } + else + { + // The options overload is the only NavigateTo that carries HistoryEntryState. Only taken + // when state was actually supplied, so the common stateless path keeps its exact + // pre-existing NavigationManager behavior. + _navigationManager!.NavigateTo(url, new NavigationOptions + { + ForceLoad = forceLoad, + ReplaceHistoryEntry = replace, + HistoryEntryState = historyState, + }); + } } public void Back() @@ -76,6 +152,11 @@ public ValueTask BackAsync(int delta = 1) if (delta < 1) throw new ArgumentOutOfRangeException(nameof(delta), delta, "Back delta must be >= 1. To go forward, use Forward / ForwardAsync."); EnsureMounted(); + // Stamp the traversal type up front: interactive Blazor reports history traversals as + // intercepted navigations, which the pipeline's fallback heuristic reads as a push. The + // pending marker wins, so guards/hooks see the correct Pop. (If the traversal is a no-op - + // history boundary - the marker is consumed by the next navigation instead; rare and benign.) + _activeBrouter!.SetPendingNavigationType(BrouterNavigationType.Pop); return GoAsync(-delta); } @@ -90,27 +171,115 @@ public ValueTask ForwardAsync(int delta = 1) if (delta < 1) throw new ArgumentOutOfRangeException(nameof(delta), delta, "Forward delta must be >= 1. To go back, use Back / BackAsync."); EnsureMounted(); + // See BackAsync: mark the traversal so the pipeline reports Pop rather than a push. + _activeBrouter!.SetPendingNavigationType(BrouterNavigationType.Pop); return GoAsync(delta); } - private async ValueTask GoAsync(int delta) + public ValueTask RevalidateAsync() { + EnsureMounted(); + return new ValueTask(_activeBrouter!.RevalidateAsync()); + } + + public ValueTask PreloadAsync(string url) + { + EnsureMounted(); + url = BrouterRelativeUrl.ResolveIfRelative(Location.Path, url); + return new ValueTask(_activeBrouter!.PreloadAsync(url)); + } + + public void NavigateWithQuery(Action mutate, bool replace = true) + { + ArgumentNullException.ThrowIfNull(mutate); + EnsureMounted(); + + var location = Location; + var builder = new BrouterQueryBuilder(location); + mutate(builder); + + Navigate(location.Path + builder.ToQueryString() + location.Hash, replace: replace); + } + + /// + /// Starts a View Transition capturing the current (outgoing) DOM, returning true only when a + /// transition is actually running and will need after + /// the new route's render lands. False on unsupported browsers, during prerender, disconnected + /// circuits, or when is off - the pipeline then + /// skips the completion round-trip entirely. + /// + internal async ValueTask BeginViewTransitionAsync(BrouterNavigationType navigationType) + { + if (_options.ViewTransitions is false) return false; + + // The direction token drives the built-in direction-aware default animations (push glides + // forward, pop mirrors it, replace fades in place) and is exposed on the root element as + // data-brouter-nav for custom CSS. + var kind = navigationType switch + { + BrouterNavigationType.Replace => "replace", + BrouterNavigationType.Pop => "pop", + _ => "push", + }; + try { - // history.go(0) reloads the page; we reject delta == 0 above so we never hit that. - await _js.InvokeVoidAsync("history.go", delta).ConfigureAwait(false); + var module = await GetModuleAsync(); + return await module.InvokeAsync("beginViewTransition", kind, + _options.ViewTransitionDefaultAnimations, _options.ViewTransitionRespectReducedMotion); } - catch (JSDisconnectedException) { /* Circuit disconnected; nothing to do. */ } - catch (JSException) { /* JS interop failure; nothing to do. */ } - catch (InvalidOperationException) { /* JS interop not available during pre-render. */ } - catch (TaskCanceledException) { /* Component disposed mid-call. */ } + catch (JSDisconnectedException ex) { LogSuppressedJsFailure(ex, "circuit disconnected mid-call"); } + catch (JSException ex) { LogSuppressedJsFailure(ex, "JS interop failure (e.g. non-browser host)"); } + catch (InvalidOperationException ex) { LogSuppressedJsFailure(ex, "JS interop unavailable during pre-render"); } + catch (TaskCanceledException ex) { LogSuppressedJsFailure(ex, "component disposed mid-call"); } + return false; + } + + /// Resolves the pending View Transition's update promise so the browser animates to the new DOM. + internal ValueTask CompleteViewTransitionAsync() => + SafeJsCallAsync(async () => + { + var module = await GetModuleAsync(); + await module.InvokeVoidAsync("completeViewTransition"); + }); + + public ValueTask SetConfirmExternalNavigationAsync(bool enabled) => + // Best-effort like the other fire-and-forget interop: during prerender or on a disconnected + // circuit there is no browser to arm, and the interactive pass re-arms via BrouterOptions + // when the always-on option is used. + SafeJsCallAsync(async () => + { + var module = await GetModuleAsync(); + await module.InvokeVoidAsync("setConfirmExternalNavigation", enabled); + }); + + private ValueTask GoAsync(int delta) => + // history.go(0) reloads the page; we reject delta == 0 above so we never hit that. + SafeJsCallAsync(() => _js.InvokeVoidAsync("history.go", delta)); + + // Runs a JS interop call, swallowing the four failures that are expected and non-fatal for + // Brouter's fire-and-forget interop (navigation effects, scroll save, history.go, module dispose): + // a disconnected circuit, a generic interop failure (e.g. a non-browser host), interop being + // unavailable during prerender, and the component being disposed mid-call. Centralized so every + // JS call site handles the same set identically instead of repeating the catch block. Suppressed + // failures are logged at Debug so real problems remain diagnosable without becoming fatal. + private async ValueTask SafeJsCallAsync(Func call) + { + try { await call(); } + catch (JSDisconnectedException ex) { LogSuppressedJsFailure(ex, "circuit disconnected mid-call"); } + catch (JSException ex) { LogSuppressedJsFailure(ex, "JS interop failure (e.g. non-browser host)"); } + catch (InvalidOperationException ex) { LogSuppressedJsFailure(ex, "JS interop unavailable during pre-render"); } + catch (TaskCanceledException ex) { LogSuppressedJsFailure(ex, "component disposed mid-call"); } } + private void LogSuppressedJsFailure(Exception exception, string reason) => + _logger.LogDebug(exception, "Suppressed a non-fatal Brouter JS interop failure ({Reason}).", reason); + public void NavigateToName(string name, IReadOnlyDictionary? parameters = null, - string? query = null, bool replace = false) + string? query = null, bool replace = false, string? historyState = null) { var url = ResolveUrl(name, parameters, query); - Navigate(url, replace: replace); + Navigate(url, replace: replace, historyState: historyState); } public string ResolveUrl(string name, IReadOnlyDictionary? parameters = null, string? query = null) @@ -137,6 +306,11 @@ public string ResolveUrl(string name, IReadOnlyDictionary? para var optionalOmitted = false; string? omittedOptionalName = null; + // Names of template parameters, filled in during the segment walk below. Any dictionary + // entry whose key isn't in this set is appended as a query-string pair after the path, + // rather than being silently dropped (mirrors ASP.NET's LinkGenerator / React Router). + var consumedNames = normalizedParams is null ? null : new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var segment in route.RouteTemplate.TemplateSegments) { sb.Append('/'); @@ -152,6 +326,8 @@ public string ResolveUrl(string name, IReadOnlyDictionary? para continue; } + consumedNames?.Add(segment.Value); + var hasValue = normalizedParams is not null && normalizedParams.TryGetValue(segment.Value, out var raw) && raw is not null; if (hasValue is false) { @@ -252,21 +428,56 @@ public string ResolveUrl(string name, IReadOnlyDictionary? para if (sb.Length == 0) sb.Append('/'); + var hasQuery = false; if (string.IsNullOrEmpty(query) is false) { sb.Append(query.StartsWith('?') ? query : "?" + query); + hasQuery = true; + } + + // Dictionary entries that didn't bind to a template parameter become query-string pairs. + // Null values are skipped (null already means "absent" for route parameters, so the same + // convention applies here); non-string enumerables emit one pair per element ("tag=a&tag=b"). + if (normalizedParams is not null) + { + foreach (var (key, value) in normalizedParams) + { + if (consumedNames!.Contains(key) || value is null) continue; + + if (value is not string && value is System.Collections.IEnumerable items) + { + foreach (var item in items) + { + if (item is null) continue; + AppendQueryPair(sb, key, FormatRouteValue(item), ref hasQuery); + } + } + else + { + AppendQueryPair(sb, key, FormatRouteValue(value), ref hasQuery); + } + } } return sb.ToString(); } + private static void AppendQueryPair(StringBuilder sb, string key, string value, ref bool hasQuery) + { + sb.Append(hasQuery ? '&' : '?'); + hasQuery = true; + sb.Append(Uri.EscapeDataString(key)).Append('=').Append(Uri.EscapeDataString(value)); + } + private void EnsureMounted() { if (_activeBrouter is null || _navigationManager is null) throw new InvalidOperationException("No Brouter is currently mounted."); } - private static string FormatRouteValue(object? value) + // Internal (not private): BrouterQueryBuilder formats query values with the identical rules so + // ResolveUrl-emitted and builder-emitted parameters always round-trip the same way. + internal static string FormatRouteValue(object? value) { if (value is null) return string.Empty; @@ -347,18 +558,108 @@ internal async ValueTask InvokeOnError(BrouterNavigationContext ctx, Exception? } - internal async ValueTask ApplyScrollAsync() + /// + /// Applies the post-navigation DOM effects for : scrolling a URL + /// fragment into view, moving focus for assistive technologies (see + /// ), and scroll-to-top. Invoked from + /// Brouter.OnAfterRenderAsync once the matched route is committed to the DOM, so fragment + /// and focus selectors resolve against the new page content rather than the previous one. + /// + internal async ValueTask ApplyNavigationEffectsAsync(BrouterLocation location) + { + var hash = location.Hash; + var scrollToFragment = _options.ScrollToFragment && string.IsNullOrEmpty(hash) is false; + var scrollToTop = _options.ScrollBehavior == BrouterScrollMode.ToTop; + var focusSelector = _options.FocusOnNavigateSelector; + var hasFocus = string.IsNullOrEmpty(focusSelector) is false; + var restore = _options.RestoreScrollPosition; + + // Nothing configured for this navigation -> don't even import the JS module. + if (scrollToFragment is false && scrollToTop is false && hasFocus is false && restore is false) return; + + // No ConfigureAwait(false): this is awaited from Brouter.OnAfterRenderAsync, so stay on the + // renderer's synchronization context (required on Blazor Server for interop/state). + await SafeJsCallAsync(async () => + { + var module = await GetModuleAsync(); + + await module.InvokeVoidAsync( + "applyNavigationEffects", + scrollToFragment ? hash : null, + hasFocus ? focusSelector : null, + scrollToTop, + // On a Back/Forward the JS side restores the position remembered for this URL; the key + // is only sent when restoration is enabled so it stays inert (and browser-native + // restoration untouched) otherwise. + restore ? location.FullUri : null, + restore ? ScrollStorageKind : null); + }); + } + + /// + /// Records the scroll position of the page being navigated away from () so a + /// later Back/Forward to that URL can restore it. Invoked from the commit pipeline BEFORE the new + /// route renders, so the JS side reads the outgoing page's scroll offset rather than the new one. + /// A no-op unless is enabled and there is an + /// actual page to leave (skipped on the initial load, where is empty). + /// + internal async ValueTask SaveScrollPositionAsync(BrouterLocation from) + { + if (_options.RestoreScrollPosition is false) return; + if (string.IsNullOrEmpty(from.FullUri)) return; + + await SafeJsCallAsync(async () => + { + var module = await GetModuleAsync(); + await module.InvokeVoidAsync("saveScrollPosition", from.FullUri, ScrollStorageKind); + }); + } + + // Maps the configured storage mode to the token the JS module understands ('session'/'local'), + // or null for in-memory. Kept here so both interop call sites stay in sync. + private string? ScrollStorageKind => _options.ScrollPositionStorage switch { - if (_options.ScrollBehavior != BrouterScrollMode.ToTop) return; - // No ConfigureAwait(false): this is awaited from Brouter.ProcessNavigationAsync, which - // calls StateHasChanged() right after. That needs the renderer's synchronization context. + BrouterScrollPositionStorage.SessionStorage => "session", + BrouterScrollPositionStorage.LocalStorage => "local", + _ => null + }; + + // Internal (not private) so BrouterLink can share the scope's single module instance + // instead of each link importing its own copy. + internal async ValueTask GetModuleAsync() + { + // Exceptions bubble to the caller's catch block (SafeJsCallAsync here, BrouterLink's + // wiring try/catch), which handles the pre-render / disconnected / non-browser cases + // uniformly. A failed import must not stay cached: during pre-render interop throws, + // and a caller retrying later (once interop is available) should get a fresh attempt + // rather than the memoised failure. + var task = _moduleTask ??= _js.InvokeAsync( + "import", "./_content/Bit.Brouter/bit-brouter.js").AsTask(); + try { - await _js.InvokeVoidAsync("window.scrollTo", 0, 0); + return await task; + } + catch + { + if (ReferenceEquals(_moduleTask, task)) _moduleTask = null; + throw; + } + } + + public async ValueTask DisposeAsync() + { + var task = _moduleTask; + _moduleTask = null; + if (task is not null) + { + // If the import itself failed, awaiting it rethrows one of the four expected + // interop failures, which SafeJsCallAsync swallows. + await SafeJsCallAsync(async () => + { + var module = await task; + await module.DisposeAsync(); + }); } - catch (JSDisconnectedException) { /* circuit disconnected mid-call */ } - catch (JSException) { /* JS interop failure (e.g. non-browser host) */ } - catch (InvalidOperationException) { /* JS interop unavailable during pre-render */ } - catch (TaskCanceledException) { /* component disposed mid-call */ } } } diff --git a/src/Brouter/Bit.Brouter/BrouterStaleReloadMode.cs b/src/Brouter/Bit.Brouter/BrouterStaleReloadMode.cs new file mode 100644 index 0000000000..8e530a1c59 --- /dev/null +++ b/src/Brouter/Bit.Brouter/BrouterStaleReloadMode.cs @@ -0,0 +1,14 @@ +namespace Bit.Brouter; + +/// How a stale cached loader result is served (see ). +public enum BrouterStaleReloadMode +{ + /// + /// Render the stale data immediately and re-run the loaders in the background, re-rendering when + /// fresh data arrives - classic stale-while-revalidate (TanStack Router's default). + /// + Background = 0, + + /// Treat stale entries as cache misses: the navigation waits for the loader to finish. + Blocking = 1, +} diff --git a/src/Brouter/Bit.Brouter/BrouterTemplateParser.cs b/src/Brouter/Bit.Brouter/BrouterTemplateParser.cs index 177fb139d7..099b1dd1b5 100644 --- a/src/Brouter/Bit.Brouter/BrouterTemplateParser.cs +++ b/src/Brouter/Bit.Brouter/BrouterTemplateParser.cs @@ -15,7 +15,7 @@ internal static class BrouterTemplateParser /// Read-only view of the characters that aren't allowed inside a parameter name. public static ReadOnlySpan InvalidParameterNameCharacters => _invalidParameterNameCharacters; - internal static BrouterRouteTemplate ParseTemplate(string template) + internal static BrouterRouteTemplate ParseTemplate(string template, BrouterConstraintRegistry? constraints = null) { if (string.IsNullOrEmpty(template)) return new BrouterRouteTemplate("", []); @@ -54,7 +54,7 @@ internal static BrouterRouteTemplate ParseTemplate(string template) // Validate parameter name characters: skip '*' (catch-all prefix), ':' (constraint separator), '?' (optional suffix). ValidateParameterName(originalTemplate, segment, inner); - templateSegments[i] = new BrouterTemplateSegment(originalTemplate, inner, isParameter: true); + templateSegments[i] = new BrouterTemplateSegment(originalTemplate, inner, isParameter: true, constraints); } } diff --git a/src/Brouter/Bit.Brouter/BrouterTemplateSegment.cs b/src/Brouter/Bit.Brouter/BrouterTemplateSegment.cs index f6fd948a7e..a982ae8fd3 100644 --- a/src/Brouter/Bit.Brouter/BrouterTemplateSegment.cs +++ b/src/Brouter/Bit.Brouter/BrouterTemplateSegment.cs @@ -20,7 +20,7 @@ internal class BrouterTemplateSegment public BrouterRouteConstraintBinding[] Constraints { get; } - public BrouterTemplateSegment(string template, string segment, bool isParameter) + public BrouterTemplateSegment(string template, string segment, bool isParameter, BrouterConstraintRegistry? constraints = null) { IsParameter = isParameter; @@ -71,7 +71,7 @@ public BrouterTemplateSegment(string template, string segment, bool isParameter) Value = paramName; Constraints = rest.Split(':') - .Select(c => new BrouterRouteConstraintBinding(c, BrouterRouteConstraint.Resolve(template, segment, c))) + .Select(c => new BrouterRouteConstraintBinding(c, BrouterRouteConstraint.Resolve(template, segment, c, constraints))) .ToArray(); } diff --git a/src/Brouter/Bit.Brouter/BrouterView.cs b/src/Brouter/Bit.Brouter/BrouterView.cs new file mode 100644 index 0000000000..45121b6577 --- /dev/null +++ b/src/Brouter/Bit.Brouter/BrouterView.cs @@ -0,0 +1,75 @@ +namespace Bit.Brouter; + +/// +/// Declares a named view fragment for the enclosing : when the route matches, +/// each of its parent's same-named <BrouterOutlet Name="..."> outlets renders the +/// corresponding fragment - so one route can drive multiple regions of its parent layout (main + +/// sidebar + toolbar), Vue Router named-views style. The fragment receives the route's merged +/// parameters, and the route's data/meta cascades are available inside it. Renders nothing at its +/// own declaration site. +/// +/// +/// +/// <Broute Path="/dashboard"> +/// <Content> +/// <main><BrouterOutlet /></main> +/// <aside><BrouterOutlet Name="sidebar" /></aside> +/// </Content> +/// <ChildContent> +/// <Broute Path="/stats"> +/// <Content>stats main</Content> +/// <ChildContent> +/// <BrouterView Name="sidebar" Context="p">stats sidebar</BrouterView> +/// </ChildContent> +/// </Broute> +/// </ChildContent> +/// </Broute> +/// +/// +public sealed class BrouterView : ComponentBase, IDisposable +{ + [CascadingParameter(Name = "ParentRoute")] internal Broute? Route { get; set; } + + /// The outlet name this view targets. Must be non-empty (the primary outlet renders the route's Content). + [Parameter, EditorRequired] public string Name { get; set; } = string.Empty; + + /// The fragment to render in the same-named parent outlet; receives the route's parameters. + [Parameter] public RenderFragment? ChildContent { get; set; } + + private Broute? _registeredRoute; + private string? _registeredName; + + protected override void OnParametersSet() + { + if (Route is null) + throw new InvalidOperationException( + "A BrouterView must be declared inside a Broute (its ChildContent), whose parent layout hosts the named outlet."); + + if (string.IsNullOrEmpty(Name)) + throw new InvalidOperationException( + "BrouterView requires a non-empty Name. The route's main content already renders in the primary (unnamed) outlet via Content/Component."); + + // Re-register every parameter pass: a host re-render produces a fresh fragment instance + // that the outlet must pick up, and a changed Name - or a new cascaded Route instance - + // must vacate the old slot on the route it was registered with. + if (_registeredName is not null && + (ReferenceEquals(_registeredRoute, Route) is false + || string.Equals(_registeredName, Name, StringComparison.Ordinal) is false)) + { + _registeredRoute?.SetNamedView(_registeredName, null); + } + Route.SetNamedView(Name, ChildContent); + _registeredRoute = Route; + _registeredName = Name; + } + + public void Dispose() + { + if (_registeredName is not null) + { + _registeredRoute?.SetNamedView(_registeredName, null); + _registeredRoute = null; + _registeredName = null; + } + } +} diff --git a/src/Brouter/Bit.Brouter/IBrouter.cs b/src/Brouter/Bit.Brouter/IBrouter.cs index 6e2ad68f57..d749004f0b 100644 --- a/src/Brouter/Bit.Brouter/IBrouter.cs +++ b/src/Brouter/Bit.Brouter/IBrouter.cs @@ -14,13 +14,39 @@ public interface IBrouter /// /// Imperatively navigate to a URL. /// - /// Destination URL or path. + /// Destination URL or path. A route-relative path (./x, ../x) + /// is resolved against the current location using segment math: from /users/42, + /// "./edit" navigates to /users/42/edit and "../7" to /users/7. + /// Bare paths without a leading . keep their base-relative meaning. /// If true, replaces the current history entry instead of pushing a new one. /// Ignored when is true. /// If true, performs a full-page reload. The Brouter pipeline /// (OnNavigating, route guards, loaders, and OnNavigated) is skipped because /// the SPA process is replaced by the new document. - void Navigate(string url, bool replace = false, bool forceLoad = false); + /// Optional application state to attach to the destination's history + /// entry (history.state, via NavigationManager.HistoryEntryState). Read it back on + /// after the navigation commits - including when the + /// user later returns to the entry via Back/Forward. Serialize structured data (e.g. JSON) + /// yourself; the value survives history traversals but not necessarily a full reload. + void Navigate(string url, bool replace = false, bool forceLoad = false, string? historyState = null); + + /// + /// Imperatively navigate to a URL and await how the navigation concluded - committed, + /// cancelled by a guard, redirected, not found, failed, or superseded by a newer navigation + /// (see ). Mirrors Vue Router's awaitable + /// router.push with navigation failures. URL resolution (including route-relative + /// ./x / ../x) matches ; full-page reloads + /// (forceLoad) are not offered here because the SPA process that would resolve the + /// task is replaced by the new document. + /// + /// + /// Default implementation throws ; the shipped + /// service implements it. Override on custom test doubles if needed. + /// + ValueTask NavigateAsync(string url, bool replace = false, string? historyState = null) => + throw new NotSupportedException( + $"This {nameof(IBrouter)} implementation does not support {nameof(NavigateAsync)}. " + + "Override the method on your custom implementation to enable awaited navigation."); /// /// Navigate one entry back in history. Fire-and-forget; failures (e.g. JS interop @@ -60,13 +86,120 @@ ValueTask ForwardAsync(int delta = 1) => $"This {nameof(IBrouter)} implementation does not support {nameof(ForwardAsync)}. " + "Override the method on your custom implementation to enable history navigation."); - /// Navigate to a named route, substituting the given parameters into the path. + /// + /// Navigate to a named route, substituting the given parameters into the path. + /// Parameters that don't match a template parameter are appended as query-string pairs + /// (after , if provided). attaches + /// application state to the destination's history entry (see ). + /// void NavigateToName(string name, IReadOnlyDictionary? parameters = null, - string? query = null, bool replace = false); + string? query = null, bool replace = false, string? historyState = null); - /// Build a URL for a named route without navigating. + /// + /// Build a URL for a named route without navigating. Parameters that don't match a template + /// parameter are appended as query-string pairs (after , if provided); + /// entries with a value are skipped, and non-string enumerable values + /// emit one pair per element (e.g. ?tag=a&tag=b). + /// string ResolveUrl(string name, IReadOnlyDictionary? parameters = null, string? query = null); + /// + /// Re-runs the loaders of the currently matched route chain and re-renders with the fresh data - + /// call after a mutation so the screen reflects it (React Router revalidation / SvelteKit + /// invalidateAll style). Not a navigation: the URL stays, guards and navigation hooks do + /// not run, and the current content remains visible while loaders work. Loaders can detect it + /// via . Completes when the fresh data has + /// been committed (or the revalidation was superseded by a navigation). + /// + /// + /// Default implementation throws ; the shipped + /// service implements it. Override on custom test doubles if needed. + /// + ValueTask RevalidateAsync() => + throw new NotSupportedException( + $"This {nameof(IBrouter)} implementation does not support {nameof(RevalidateAsync)}. " + + "Override the method on your custom implementation to enable revalidation."); + + /// + /// Navigates to the current path with a functionally-updated query string: + /// receives a seeded with the current query, and every parameter + /// it doesn't touch is preserved - e.g. brouter.NavigateWithQuery(q => q.Set("page", 2)) + /// bumps the page while keeping filters/sort intact (TanStack Router's functional search updates). + /// Replaces the history entry by default, the natural fit for query-as-UI-state; pass + /// = false to push instead. The fragment is preserved. + /// + /// + /// Default implementation throws ; the shipped + /// service implements it. Override on custom test doubles if needed. + /// + void NavigateWithQuery(Action mutate, bool replace = true) => + throw new NotSupportedException( + $"This {nameof(IBrouter)} implementation does not support {nameof(NavigateWithQuery)}. " + + "Override the method on your custom implementation to enable query updates."); + + /// + /// Speculatively runs the loaders of the route that would match, warming + /// the loader cache so the actual navigation is instant (see also + /// for declarative hover/viewport preloading). Guards don't run, nothing renders, failures are + /// swallowed; loaders can detect it via . + /// + /// + /// Default implementation throws ; the shipped + /// service implements it. Override on custom test doubles if needed. + /// + ValueTask PreloadAsync(string url) => + throw new NotSupportedException( + $"This {nameof(IBrouter)} implementation does not support {nameof(PreloadAsync)}. " + + "Override the method on your custom implementation to enable preloading."); + + /// + /// Drops every cached loader result (see and link preloading), so + /// subsequent navigations re-run their loaders. The blunt companion to + /// : call it after a mutation that invalidates data on pages other + /// than the current one. + /// + /// + /// Default implementation throws ; the shipped + /// service implements it. Override on custom test doubles if needed. + /// + void ClearLoaderCache() => + throw new NotSupportedException( + $"This {nameof(IBrouter)} implementation does not support {nameof(ClearLoaderCache)}. " + + "Override the method on your custom implementation to enable loader-cache invalidation."); + + /// + /// Releases every route's retained (hidden) component, leaving + /// only the currently visible route mounted. The kept components are disposed, so a later return + /// to one of them recreates it fresh instead of restoring its state. Use it to reclaim memory + /// held by kept pages - e.g. on sign-out, on a low-memory signal, or after invalidating the state + /// those pages were holding. A no-op when nothing is being kept. + /// + /// + /// Default implementation throws ; the shipped + /// service implements it. Override on custom test doubles if needed. + /// + void ClearKeepAlive() => + throw new NotSupportedException( + $"This {nameof(IBrouter)} implementation does not support {nameof(ClearKeepAlive)}. " + + "Override the method on your custom implementation to enable keep-alive eviction."); + + /// + /// Arms (true) or disarms (false) the browser's generic "leave site?" confirmation + /// for external navigations - closing the tab, a full reload, or following a link out of the SPA. + /// Idempotent in both directions, so a dirty-form tracker can toggle it freely. Browser rules + /// apply (dialog requires prior user interaction; text not customizable). In-SPA navigations are + /// covered by / instead. See also + /// for the always-on variant. + /// + /// + /// Default implementation throws ; the shipped + /// service implements it. Override on custom test doubles if needed. + /// + ValueTask SetConfirmExternalNavigationAsync(bool enabled) => + throw new NotSupportedException( + $"This {nameof(IBrouter)} implementation does not support {nameof(SetConfirmExternalNavigationAsync)}. " + + "Override the method on your custom implementation to enable external-navigation confirmation."); + /// Async hook fired before any navigation. Inspect/cancel/redirect via the context. event Func? OnNavigating; diff --git a/src/Brouter/Bit.Brouter/Scripts/bit-brouter.ts b/src/Brouter/Bit.Brouter/Scripts/bit-brouter.ts new file mode 100644 index 0000000000..cff3480bba --- /dev/null +++ b/src/Brouter/Bit.Brouter/Scripts/bit-brouter.ts @@ -0,0 +1,530 @@ +// Wires a capture-phase click listener on the given anchor that calls preventDefault +// ONLY for unmodified primary clicks. Modified clicks (Ctrl/Cmd/Shift/Alt) and non- +// primary buttons keep their native browser behavior (e.g., "open in new tab"). +// +// Blazor's render-time `onclick:preventDefault` attribute can't be toggled per click, +// so it would otherwise suppress the default action even on modified clicks. With +// this listener installed, Blazor's own onclick handler still fires (and the C# side +// applies the same modifier checks before performing the replace navigation), but +// the browser default is left alone for modified clicks. +export function wireConditionalPreventDefault(element: HTMLElement | null) { + if (!element) return null; + + const handler = (e: MouseEvent) => { + if (e.defaultPrevented) return; + if (e.button !== 0) return; + if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return; + e.preventDefault(); + }; + + // Capture phase so we run before Blazor's bubble-phase onclick handler. + element.addEventListener('click', handler, { capture: true }); + + return { + dispose: () => element.removeEventListener('click', handler, { capture: true }) + }; +} + +// --------------------------------------------------------------------------------------------- +// Link preloading (BrouterLink.Preload). Wires the DOM triggers for the two JS-driven modes and +// calls back into the BrouterLink instance, which resolves the target route and runs its loaders +// into the cache. 'intent' fires on pointer hover / touchstart / keyboard focus after a small +// debounce (leaving cancels a pending fire); 'viewport' fires once when the link first becomes +// visible. Repeated fires are cheap: the C# side short-circuits on a still-fresh cache entry. + +export function wirePreload(element: HTMLElement | null, mode: string, delayMs: number, dotnetRef: any) { + if (!element || !dotnetRef) return null; + + const trigger = () => { try { dotnetRef.invokeMethodAsync('OnPreloadTriggered'); } catch { /* disposed */ } }; + + if (mode === 'intent') { + let timer: number | null = null; + const arm = () => { + if (timer !== null) return; + timer = window.setTimeout(() => { timer = null; trigger(); }, delayMs); + }; + const disarm = () => { + if (timer !== null) { window.clearTimeout(timer); timer = null; } + }; + element.addEventListener('pointerenter', arm); + element.addEventListener('pointerleave', disarm); + element.addEventListener('touchstart', arm, { passive: true }); + element.addEventListener('focus', arm); + element.addEventListener('blur', disarm); + return { + dispose: () => { + disarm(); + element.removeEventListener('pointerenter', arm); + element.removeEventListener('pointerleave', disarm); + element.removeEventListener('touchstart', arm); + element.removeEventListener('focus', arm); + element.removeEventListener('blur', disarm); + } + }; + } + + if (mode === 'viewport') { + if (typeof IntersectionObserver !== 'function') return null; + const observer = new IntersectionObserver(entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + observer.disconnect(); + trigger(); + break; + } + } + }); + observer.observe(element); + return { dispose: () => observer.disconnect() }; + } + + return null; +} + +// --------------------------------------------------------------------------------------------- +// View Transitions API integration (BrouterOptions.ViewTransitions). +// +// Blazor renders asynchronously, so the classic synchronous startViewTransition(update) shape +// doesn't fit: the DOM mutation happens whenever the render batch lands, not inside a callback we +// control. The handshake is therefore split: beginViewTransition() is called by the C# pipeline +// right BEFORE it triggers the new route's render - startViewTransition snapshots the old page and +// receives an update promise we hold open - and completeViewTransition() is called from +// OnAfterRenderAsync once the new DOM is committed, resolving that promise so the browser +// snapshots the new state and runs the crossfade (customizable per-element with the standard +// view-transition-name CSS). +// +// TWO TIMING SUBTLETIES (each was a real bug): +// +// 1. CAPTURE ORDERING. startViewTransition captures the OLD page state at the next rendering +// opportunity (~one frame later) - NOT synchronously. If beginViewTransition responded to C# +// as soon as startViewTransition returned, a fast circuit would apply the new route's render +// batch BEFORE that capture, so the "old" snapshot would picture the NEW page: old == new, +// shared-element morphs have nothing to morph between, and every navigation degenerates to an +// identical fade. The fix: beginViewTransition returns a Promise that resolves from INSIDE the +// update callback - the spec guarantees the callback is invoked only after the old state is +// captured - and JSInterop awaits JS promises, so C# doesn't start rendering until the capture +// is truly done. (The spec also guarantees the callback is always invoked, even for skipped +// transitions; the begin watchdog below is belt-and-braces for broken implementations.) +// +// 2. COMPLETION RACING THE CALLBACK. A completion that arrives before the update callback has +// parked its resolver would silently no-op, orphaning the promise - the transition then holds +// the page frozen (pointer blocked, rendering suppressed) until the browser's ~4s timeout +// aborts it with "Transition was aborted because of timeout in DOM update". With the promise- +// based begin above, C# can't normally complete before the callback runs, but the +// completedEarly flag keeps the degraded paths (begin watchdog fired, duplicate completes) +// correct, and the update watchdog caps how long an open transition can ever hold the page +// (a lost completion - circuit drop, error path - degrades to a skipped animation, not a freeze). + +let activeViewTransitionResolve: (() => void) | null = null; +// Set when completeViewTransition() arrives before the browser has invoked the current +// transition's update callback (see timing note 2). Consumed by that callback. +let viewTransitionCompletedEarly = false; +// Upper bound on how long the update promise may stay open. The C# side completes right after the +// route's render lands (loaders run BEFORE the transition begins), so legitimate completions +// arrive within a few frames; well under a second even on a slow connection. +const viewTransitionWatchdogMs = 1500; +// Upper bound on waiting for the old-state capture (the update callback's invocation). Normally +// one frame; if it never comes (pathological implementation), navigation must not hang. +const viewTransitionBeginWatchdogMs = 500; + +// Brouter's out-of-the-box navigation animations (BrouterOptions.ViewTransitionDefaultAnimations). +// Direction-aware: a push glides the new page in, pop (Back/Forward) mirrors the motion, replace +// does a quick in-place fade; shared-element morphs get a springy glide. prefers-reduced-motion +// (which Windows/macOS accessibility settings propagate into the browser - "Animation effects" +// off on Windows means EVERY user of that machine reports reduce) swaps the slides for gentle +// opacity-only crossfades and disables the morph motion, so navigation still gives visual feedback +// instead of going dead. Injected once, inside the CSS layer "bit-brouter", +// so any UNLAYERED ::view-transition-* rule in application CSS overrides these automatically - +// zero specificity fights. The current direction is exposed as data-brouter-nav on . +const viewTransitionDefaultCss = ` +::view-transition-old(root) { animation: 170ms cubic-bezier(.4,0,1,1) both bit-brouter-vt-out-fwd; } +::view-transition-new(root) { animation: 300ms cubic-bezier(.22,1,.36,1) 30ms both bit-brouter-vt-in-fwd; } +html[data-brouter-nav="pop"]::view-transition-old(root) { animation-name: bit-brouter-vt-out-back; } +html[data-brouter-nav="pop"]::view-transition-new(root) { animation-name: bit-brouter-vt-in-back; } +html[data-brouter-nav="replace"]::view-transition-old(root) { animation: 120ms ease-out both bit-brouter-vt-fade-out; } +html[data-brouter-nav="replace"]::view-transition-new(root) { animation: 170ms ease-in both bit-brouter-vt-fade-in; } +::view-transition-group(*) { animation-duration: 320ms; animation-timing-function: cubic-bezier(.22,1,.36,1); } +@keyframes bit-brouter-vt-out-fwd { to { opacity: 0; transform: translateX(-28px); } } +@keyframes bit-brouter-vt-in-fwd { from { opacity: 0; transform: translateX(34px); } } +@keyframes bit-brouter-vt-out-back { to { opacity: 0; transform: translateX(28px); } } +@keyframes bit-brouter-vt-in-back { from { opacity: 0; transform: translateX(-34px); } } +@keyframes bit-brouter-vt-fade-out { to { opacity: 0; } } +@keyframes bit-brouter-vt-fade-in { from { opacity: 0; } }`; + +// Included only when BrouterOptions.ViewTransitionRespectReducedMotion is true (the default). +// Bypassing it is legitimate when the OS reports reduce for non-accessibility reasons (Windows +// "Animation effects" off on VMs / remote desktops / perf-tuned machines) - the C# option holds +// the rationale; here we just honor the flag. +const viewTransitionReducedMotionCss = ` +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(root), + html[data-brouter-nav]::view-transition-old(root) { animation: 120ms ease-out both bit-brouter-vt-fade-out; } + ::view-transition-new(root), + html[data-brouter-nav]::view-transition-new(root) { animation: 160ms ease-in both bit-brouter-vt-fade-in; } + ::view-transition-group(*) { animation-duration: 1ms; animation-delay: 0ms; } +}`; + +let viewTransitionDefaultsInjected = false; + +function ensureViewTransitionDefaults(useDefaults: boolean, respectReducedMotion: boolean) { + if (!useDefaults || viewTransitionDefaultsInjected) return; + viewTransitionDefaultsInjected = true; + const style = document.createElement('style'); + style.id = 'bit-brouter-view-transitions'; + style.textContent = '@layer bit-brouter {' + viewTransitionDefaultCss + + (respectReducedMotion ? viewTransitionReducedMotionCss : '') + '\n}'; + document.head.appendChild(style); +} + +// History-traversal detection for the animation direction. The C# side classifies a navigation +// from what the framework reports, but interactive Blazor flags Back/Forward history traversals +// as "intercepted", which reads as a push. The browser knows the truth: popstate fires exactly on +// history traversals (never on pushState link navigations), and it fires BEFORE the navigation +// pipeline's beginViewTransition interop arrives - so a module-scope listener can correct the +// direction reliably for both the browser buttons and programmatic history.go/back/forward. +let historyTraversalPending = false; +if (typeof window !== 'undefined') { + window.addEventListener('popstate', () => { historyTraversalPending = true; }); +} + +// Resolves true once a transition is started AND its old-state capture is complete (the C# side +// awaits this before rendering the new route - see timing note 1); false lets the C# side skip +// the completion round-trip entirely on unsupported browsers. +// navKind - 'push' | 'replace' | 'pop'; drives the direction-aware default +// animations and is exposed as data-brouter-nav on the root element. +// useDefaults - whether to inject Brouter's default animation stylesheet (once). +// respectReducedMotion - whether the injected defaults include the prefers-reduced-motion +// fallback (BrouterOptions.ViewTransitionRespectReducedMotion). +export function beginViewTransition(navKind?: string, useDefaults?: boolean, respectReducedMotion?: boolean): Promise { + const doc = document as any; + if (typeof doc.startViewTransition !== 'function') return Promise.resolve(false); + + ensureViewTransitionDefaults(useDefaults !== false, respectReducedMotion !== false); + // Stamp the direction BEFORE the transition starts so the pseudo-element animations resolve + // against it. It persists (harmlessly) until the next navigation overwrites it. A pending + // popstate overrides the reported kind: the framework cannot distinguish Back/Forward from a + // push in interactive mode (see historyTraversalPending above), but the browser can. + const kind = historyTraversalPending ? 'pop' : (navKind || 'push'); + historyTraversalPending = false; + document.documentElement.setAttribute('data-brouter-nav', kind); + + // A still-open previous transition (its navigation was superseded mid-flight) must be released + // first: the browser skips/settles it and lets the new one start cleanly. + if (activeViewTransitionResolve) { + activeViewTransitionResolve(); + activeViewTransitionResolve = null; + } + viewTransitionCompletedEarly = false; + + return new Promise(beginResolve => { + let beginResolved = false; + const resolveBegin = (started: boolean) => { + if (beginResolved) return; + beginResolved = true; + beginResolve(started); + }; + + try { + const transition = doc.startViewTransition(() => { + // Invoked by the browser once the old state is captured: NOW C# may render the new + // route (the render batch lands while rendering is paused, exactly as the API + // intends), then complete to trigger the new-state capture and the animation. + resolveBegin(true); + + // The completion may already have arrived (only via the degraded begin-watchdog + // path) - resolve immediately instead of parking a resolver nothing would call. + if (viewTransitionCompletedEarly) { + viewTransitionCompletedEarly = false; + return Promise.resolve(); + } + return new Promise(resolve => { + activeViewTransitionResolve = resolve; + // Watchdog: never let an open transition hold the page hostage. setTimeout + // still fires while rendering is suppressed, so this releases even a fully + // frozen page. + window.setTimeout(() => { + if (activeViewTransitionResolve === resolve) activeViewTransitionResolve = null; + resolve(); // idempotent if the normal completion already ran + }, viewTransitionWatchdogMs); + }); + }); + // We deliberately discard the transition, so its promises reject unobserved when a + // rapid follow-up navigation skips it (AbortError: "Transition was skipped") - attach + // no-op handlers to keep that expected outcome out of the console. + transition?.finished?.catch?.(() => { /* skipped/aborted transitions are expected */ }); + transition?.updateCallbackDone?.catch?.(() => { /* ditto */ }); + transition?.ready?.catch?.(() => { /* ditto */ }); + + // Belt-and-braces: the spec guarantees the update callback always runs, but a broken + // host must not leave the navigation pipeline awaiting forever. Reporting true keeps + // the completion handshake alive so the transition (if any) is still released. + window.setTimeout(() => resolveBegin(true), viewTransitionBeginWatchdogMs); + } catch { + // Defensive: a host with a broken/partial implementation must not break navigation. + activeViewTransitionResolve = null; + resolveBegin(false); + } + }); +} + +// Resolves the pending transition's update promise; the browser then animates old -> new. +// Idempotent: completing with no pending transition is a no-op - EXCEPT that a completion racing +// ahead of the update callback must be remembered (completedEarly), or the transition freezes. +export function completeViewTransition() { + if (activeViewTransitionResolve) { + activeViewTransitionResolve(); + activeViewTransitionResolve = null; + } else { + viewTransitionCompletedEarly = true; + } +} + +// --------------------------------------------------------------------------------------------- +// External-navigation confirmation (BrouterOptions.ConfirmExternalNavigation / +// IBrouter.SetConfirmExternalNavigationAsync). While armed, leaving the SPA entirely - closing the +// tab, a full reload, or following a link to another origin/document - triggers the browser's +// generic "unsaved changes" dialog. Browsers only honor beforeunload after a user interaction with +// the page (sticky activation), and the dialog text is not customizable; both are platform rules. +// In-SPA navigations are unaffected (use leave guards / OnNavigating for those). + +let confirmExternalArmed = false; + +const beforeUnloadHandler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + // Chrome (and pre-standard browsers) require returnValue to be set for the dialog to appear. + e.returnValue = ''; +}; + +// Arms/disarms the beforeunload confirmation. Idempotent in both directions so C# callers can +// toggle freely (e.g. a dirty-form tracker flipping it on and off). +export function setConfirmExternalNavigation(enabled: boolean) { + if (enabled && !confirmExternalArmed) { + window.addEventListener('beforeunload', beforeUnloadHandler); + confirmExternalArmed = true; + } else if (!enabled && confirmExternalArmed) { + window.removeEventListener('beforeunload', beforeUnloadHandler); + confirmExternalArmed = false; + } +} + +// --------------------------------------------------------------------------------------------- +// Scroll restoration state (only used when BrouterOptions.RestoreScrollPosition is enabled). +// +// scrollPositions : absolute-URL -> { x, y } scroll offset the user was at when they left that URL. +// Kept in memory for the page's lifetime; does not survive a full reload. +// pendingIsPop : whether the navigation currently being committed is a Back/Forward (history pop). +// Captured from the popstate flag at navigation start (see saveScrollPosition) so it +// is read before any render, then consumed by applyNavigationEffects post-render. +// popped : set by the popstate listener the instant the browser fires a Back/Forward, before +// Blazor's async LocationChanged pipeline runs. Drained into pendingIsPop at save time. +type ScrollPosition = { x: number, y: number }; +type ScrollStorageKind = 'session' | 'local' | null; + +const scrollPositions = new Map(); +let pendingIsPop = false; +let popped = false; +let scrollRestorationInited = false; +// null -> in-memory only; 'session'/'local' -> mirrored to sessionStorage/localStorage so positions +// survive a full reload. Fixed for the module's lifetime (BrouterOptions are per-scope constants). +let scrollStorageKind: ScrollStorageKind = null; + +// The single web-storage slot the whole position map is JSON-serialized into. One slot (rather than +// one per URL) keeps hydrate/persist trivial and easy to clear. +const SCROLL_STORAGE_KEY = 'bit-brouter:scrollPositions'; + +// Upper bound on how many URLs' scroll positions we retain. Without a cap, every distinct URL visited +// in a long-lived session adds an entry forever, growing memory and eventually overflowing Web Storage +// (which throws QuotaExceededError on persist). A few dozen is plenty for realistic Back/Forward depth; +// the oldest entries are evicted first (see saveScrollPosition). +const MAX_SCROLL_POSITIONS = 50; + +// Resolves the configured Web Storage object, or null when persistence is off or the store is +// unavailable (private mode, disabled by policy). Accessing window.sessionStorage/localStorage can +// itself throw, so it's guarded. +function scrollStore(): Storage | null { + try { + if (scrollStorageKind === 'session') return window.sessionStorage; + if (scrollStorageKind === 'local') return window.localStorage; + } catch { /* storage access denied -> behave as in-memory */ } + return null; +} + +// Loads any previously-persisted positions into the in-memory map. Best-effort: corrupt or +// unreadable storage simply leaves the map as-is so restoration degrades to in-memory. +function hydrateScrollPositions() { + const store = scrollStore(); + if (!store) return; + try { + const raw = store.getItem(SCROLL_STORAGE_KEY); + if (!raw) return; + const obj = JSON.parse(raw); + if (!obj || typeof obj !== 'object') return; + for (const k of Object.keys(obj)) { + const v = obj[k]; + if (v && typeof v.x === 'number' && typeof v.y === 'number') { + scrollPositions.set(k, { x: v.x, y: v.y }); + } + } + } catch { /* corrupt/unavailable -> keep whatever is already in memory */ } +} + +// Write-through of the in-memory map to the configured store. Best-effort: a quota error or an +// unavailable store is swallowed so navigation (and in-memory restoration) keep working. +function persistScrollPositions() { + const store = scrollStore(); + if (!store) return; + try { + const obj: Record = {}; + for (const [k, v] of scrollPositions) obj[k] = v; + store.setItem(SCROLL_STORAGE_KEY, JSON.stringify(obj)); + } catch { /* quota exceeded / storage unavailable -> in-memory still holds the positions */ } +} + +// Idempotently arms scroll restoration: records the storage mode, takes over the browser's automatic +// restoration (so it can't fight ours), starts tracking Back/Forward, and hydrates any persisted +// positions. Called lazily the first time a restoration-enabled navigation touches the module, so a +// consumer that never opts in pays nothing and the browser's native restoration is left exactly as it +// was. `storageKind` is honored on the first call only (options are constant per scope). +// +// storageKind - 'session' | 'local' to persist positions in the matching Web Storage, else in-memory. +function ensureScrollRestoration(storageKind: string | null) { + if (scrollRestorationInited) return; + scrollRestorationInited = true; + scrollStorageKind = (storageKind === 'session' || storageKind === 'local') ? storageKind : null; + + if ('scrollRestoration' in history) { + try { history.scrollRestoration = 'manual'; } catch { /* some hosts forbid setting it */ } + } + // Fires synchronously on a Back/Forward, ahead of Blazor's async LocationChanged handling, so the + // flag is already set when the ensuing saveScrollPosition call reads it. + window.addEventListener('popstate', () => { popped = true; }); + + // Seed the in-memory map from persisted storage so a reload can still restore positions. + hydrateScrollPositions(); +} + +function currentScroll(): ScrollPosition { + return { + x: window.scrollX ?? window.pageXOffset ?? 0, + y: window.scrollY ?? window.pageYOffset ?? 0 + }; +} + +// Records the scroll offset of the page being navigated away from, keyed by its absolute URL, so a +// later Back/Forward to that URL can restore it. Invoked by the C# commit pipeline BEFORE the new +// route renders, so `currentScroll()` still reflects the outgoing page. Also drains the popstate flag +// into pendingIsPop here (pre-render) because applyNavigationEffects, which needs the direction, only +// runs post-render by which point a fresh popstate could have arrived. +// +// key - the absolute URL of the page being left, or null/empty to skip recording (e.g. initial load). +// storageKind - persistence mode, honored on the first call (see ensureScrollRestoration). +export function saveScrollPosition(key: string | null, storageKind: string | null) { + ensureScrollRestoration(storageKind); + pendingIsPop = popped; + popped = false; + if (key) { + // Bound the cache. Map preserves insertion order, so deleting the first key evicts the oldest + // entry. Delete-then-set also re-inserts an updated key at the newest position, so recently + // visited URLs survive eviction (oldest-first / LRU-ish) rather than being dropped by age of + // first visit. + scrollPositions.delete(key); + while (scrollPositions.size >= MAX_SCROLL_POSITIONS) { + const oldest = scrollPositions.keys().next().value; + if (oldest === undefined) break; + scrollPositions.delete(oldest); + } + scrollPositions.set(key, currentScroll()); + persistScrollPositions(); + } +} + +// Applies the post-navigation DOM effects that Blazor's declarative rendering can't express: +// scrolling a URL fragment into view, restoring a remembered scroll position on Back/Forward, +// moving focus for assistive technologies, and scroll-to-top. Called once per successful navigation, +// after the matched route has been committed to the DOM. Every step is best-effort: a missing target +// is silently ignored so navigation never breaks. +// +// hash - the URL fragment including its leading '#', or null/empty when the caller +// disabled fragment scrolling or the destination has no fragment. +// focusSelector - a CSS selector for the element to focus (accessibility), or null to skip. +// scrollToTop - whether to scroll the window to the top when no fragment/restore claimed the scroll. +// restoreKey - the destination's absolute URL when scroll restoration is enabled, else null. On a +// Back/Forward to a URL with a remembered position, that position is restored instead +// of applying scrollToTop. +// storageKind - persistence mode ('session'/'local'/null), honored on first arm (see saveScrollPosition). +export function applyNavigationEffects(hash: string | null, focusSelector: string | null, scrollToTop: boolean, restoreKey: string | null, storageKind: string | null) { + // Consume the direction captured at navigation start. Only meaningful when restoration is on. + // ensureScrollRestoration here guarantees the position map is hydrated before the first restore, + // even on the initial load where no saveScrollPosition call precedes this one. + let isPop = false; + if (restoreKey) { + ensureScrollRestoration(storageKind); + isPop = pendingIsPop; + pendingIsPop = false; + } + + // 1. Fragment scrolling: navigating to /docs#install should land on the #install element, + // and (for keyboard/AT users) continue focus from there rather than the top of the page. + if (hash && hash.length > 1) { + let id = hash.substring(1); + try { id = decodeURIComponent(id); } catch { /* keep the raw, still-encoded fragment */ } + + const target = document.getElementById(id) + || document.querySelector(`a[name="${cssEscape(id)}"]`); + + if (target) { + target.scrollIntoView(); + // Fragment focus wins over focusSelector: the user asked to jump to this element. + focusElement(target); + return; + } + // Fragment target not found -> fall through to the restore / scroll-to-top / focus defaults. + } + + // 2. Scroll restoration on Back/Forward: return the user to where they left this page. Wins over + // scroll-to-top (that's the "new navigation" behavior). Only acts on a history pop with a + // remembered position; a first visit or a forward push falls through to the defaults below. + if (restoreKey && isPop && scrollPositions.has(restoreKey)) { + const p = scrollPositions.get(restoreKey)!; + window.scrollTo(p.x, p.y); + // Still honor focus so assistive tech announces the page; focusElement preventScroll keeps + // the restored position intact. + if (focusSelector) { + const el = document.querySelector(focusSelector); + if (el) focusElement(el); + } + return; + } + + // 3. Scroll to top (only when no fragment/restore claimed the scroll position above). + if (scrollToTop) { + window.scrollTo(0, 0); + } + + // 4. Focus management: move focus to the configured landmark/heading so screen readers + // announce the new page instead of leaving focus on the activated link. + if (focusSelector) { + const el = document.querySelector(focusSelector); + if (el) focusElement(el); + } +} + +// Focuses an element, making it programmatically focusable first if it isn't already. Uses +// preventScroll so focusing doesn't fight a scroll position already set by the caller (fragment +// scrollIntoView above, or window.scrollTo(0,0)). +function focusElement(el: HTMLElement) { + // Non-interactive elements (h1, main, div, ...) have tabIndex -1 and no explicit tabindex; + // they can't receive programmatic focus until one is added. Use -1 so they're script-focusable + // but stay out of the sequential Tab order, matching Blazor's FocusOnNavigate behavior. + if (el.tabIndex < 0 && !el.hasAttribute('tabindex')) { + el.setAttribute('tabindex', '-1'); + } + try { el.focus({ preventScroll: true }); } catch { /* element detached mid-navigation */ } +} + +// CSS.escape isn't available in every host (older WebViews); fall back to a minimal escape so the +// a[name="..."] fragment fallback can't throw on ids containing quotes/backslashes. +function cssEscape(value: string): string { + if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value); + return value.replace(/["\\]/g, '\\$&'); +} diff --git a/src/Brouter/Bit.Brouter/package-lock.json b/src/Brouter/Bit.Brouter/package-lock.json new file mode 100644 index 0000000000..fdb2806091 --- /dev/null +++ b/src/Brouter/Bit.Brouter/package-lock.json @@ -0,0 +1,511 @@ +{ + "name": "Bit.Brouter", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "esbuild": "0.28.1", + "typescript": "5.9.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/src/Brouter/Bit.Brouter/package.json b/src/Brouter/Bit.Brouter/package.json new file mode 100644 index 0000000000..c9baee8396 --- /dev/null +++ b/src/Brouter/Bit.Brouter/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "esbuild": "0.28.1", + "typescript": "5.9.3" + } +} diff --git a/src/Brouter/Bit.Brouter/tsconfig.json b/src/Brouter/Bit.Brouter/tsconfig.json new file mode 100644 index 0000000000..32c3a2e63a --- /dev/null +++ b/src/Brouter/Bit.Brouter/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "allowJs": true, + "target": "ES2019", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ "DOM", "ESNext" ], + // bit-brouter.js is consumed by the C# side as an ES module via JS `import(...)`, so unlike + // Bit.Butil (which concatenates into a single global-namespace bundle via `outFile`) the + // sources are emitted per-file into wwwroot with their `export`s preserved. + "rootDir": "Scripts", + "outDir": "wwwroot", + // Incremental type-safety hardening. Full "strict"/"noImplicitAny" would require typing + // every interop script and is deferred; these flags catch real bugs (fallthrough, missing + // returns, unreachable code) without forcing a rewrite of the loosely-typed glue. + "noImplicitThis": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "alwaysStrict": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ "./Scripts/*.ts" ] +} diff --git a/src/Brouter/Bit.Brouter/wwwroot/BitBrouter.js b/src/Brouter/Bit.Brouter/wwwroot/BitBrouter.js deleted file mode 100644 index 27f10b3a6e..0000000000 --- a/src/Brouter/Bit.Brouter/wwwroot/BitBrouter.js +++ /dev/null @@ -1,26 +0,0 @@ -// Wires a capture-phase click listener on the given anchor that calls preventDefault -// ONLY for unmodified primary clicks. Modified clicks (Ctrl/Cmd/Shift/Alt) and non- -// primary buttons keep their native browser behavior (e.g., "open in new tab"). -// -// Blazor's render-time `onclick:preventDefault` attribute can't be toggled per click, -// so it would otherwise suppress the default action even on modified clicks. With -// this listener installed, Blazor's own onclick handler still fires (and the C# side -// applies the same modifier checks before performing the replace navigation), but -// the browser default is left alone for modified clicks. -export function wireConditionalPreventDefault(element) { - if (!element) return null; - - const handler = (e) => { - if (e.defaultPrevented) return; - if (e.button !== 0) return; - if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return; - e.preventDefault(); - }; - - // Capture phase so we run before Blazor's bubble-phase onclick handler. - element.addEventListener('click', handler, { capture: true }); - - return { - dispose: () => element.removeEventListener('click', handler, { capture: true }) - }; -} diff --git a/src/Brouter/InteralDemos/Core/AppRouter.razor b/src/Brouter/InteralDemos/Core/AppRouter.razor index a3a8168e8f..5e8ac60df7 100644 --- a/src/Brouter/InteralDemos/Core/AppRouter.razor +++ b/src/Brouter/InteralDemos/Core/AppRouter.razor @@ -1,8 +1,10 @@ @inject IBrouter brouter +@inject DemoState demoState @implements IDisposable @code { private int _currentCount; + private int _dashboardVisits; private Func? _onNavigatingHandler; @@ -47,36 +49,104 @@ ctx.Redirect("/403"); } } + + // ===== data-layer demos (see DataPage / DeferredPage / UnstablePage) ===== + + // Deliberately slow so the router-level pending UI is visible, and so the + // StaleTime cache / link preloading effects are obvious (instant when warm). + private async ValueTask LoadData(BrouterNavigationContext ctx) + { + await Task.Delay(600, ctx.CancellationToken); + return new LoadedInfo(DateTime.Now, ctx.IsRevalidation, ctx.To.GetQuery("page") ?? "1"); + } + + // Deferred data: only the summary blocks navigation; the details task is returned UNAWAITED + // and streams into after the page has revealed. + private async ValueTask LoadDeferred(BrouterNavigationContext ctx) + { + await Task.Delay(300, ctx.CancellationToken); + return new DeferredReport($"Report generated at {DateTime.Now:HH:mm:ss}.", LoadSlowDetailsAsync()); + } + + private static async Task LoadSlowDetailsAsync() + { + await Task.Delay(1500); + return ["42 orders in the last hour", "7 new signups", "0 incidents"]; + } + + // Error-boundary demo: throws while DemoState.UnstableShouldFail is armed; the route's + // ErrorContent renders the failure with a heal-and-retry button. + private async ValueTask LoadUnstable(BrouterNavigationContext ctx) + { + await Task.Delay(300, ctx.CancellationToken); + if (demoState.UnstableShouldFail) + throw new InvalidOperationException("The upstream service exploded (simulated)."); + return $"Fresh data loaded at {DateTime.Now:HH:mm:ss}."; + } + + // ===== guards for the new demos ===== + + // Leave guard for /editor: while the editor is dirty, cancel preventively - the URL never moves. + private ValueTask GuardEditorLeave(BrouterNavigationContext ctx) + { + if (demoState.IsEditorDirty) ctx.Cancel(); + return ValueTask.CompletedTask; + } + + // /blocked always cancels - the OutcomesPage uses it to show a Cancelled NavigateAsync result. + private ValueTask BlockAlways(BrouterNavigationContext ctx) + { + ctx.Cancel(); + return ValueTask.CompletedTask; + } + + // Shared guard on the dashboard's pathless group: one declaration covers every child. + private ValueTask CountDashboardVisit(BrouterNavigationContext ctx) + { + _dashboardVisits++; + return ValueTask.CompletedTask; + } + + // Lazy route loading hook. This demo has nothing to load lazily, but the wiring shows the + // shape: on WebAssembly you would await LazyAssemblyLoader.LoadAssembliesAsync(...) based on + // ctx.To.Path and return the assemblies - their @page components are registered within the + // SAME navigation, so a deep link into a lazy area just works. + private ValueTask?> OnNavigate(BrouterNavigationContext ctx) + => ValueTask.FromResult?>(null); } - - +@* AppAssembly enables attribute-route discovery: components with @page / [Route] (e.g. DiscoveredPage) + are matched alongside the hand-declared routes below, without being listed here. + OnNavigateAsync is the lazy route-loading hook (see OnNavigate above). *@ + + + - + - + - + - + - + - + - + - + - + @* 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. *@ - +

Wildcard test

Matched the wildcard pattern /*/test.

-
+ @*============================================================================*@ - - - + + +

Nested route /{id:int}/hello

@@ -114,8 +184,8 @@
- - + +

Nested route /{id:int}/world

@@ -126,12 +196,12 @@
- - + + @*============================================================================*@ - +

Guarded route /g1

@@ -142,9 +212,9 @@

You made it through the guard.

-
+ - +

Guarded route /g2

@@ -155,35 +225,35 @@

You made it through the async guard.

-
+ @*============================================================================*@ - - + +

Child: /nested/n1

count
@p["count"]
-
-
+ + - - + +

Child: /nested2/n1

count
@p["count"]
-
- + +

Child: /nested2/n2

count
@p["count"]
-
-
+ + - +

Nested route /nested3

@@ -205,24 +275,24 @@
- +

Child: /nested3/n1

count
@p["count"]
-
- +
+

Child: /nested3/n2

count
@p["count"]
-
+ - + @*============================================================================*@ - +

404

@@ -230,9 +300,9 @@ Back to home
-
+ - +

403

@@ -240,5 +310,135 @@ Back to home
-
+ + + @*============================================================================*@ + @* Data layer: loader + StaleTime cache + revalidation + query updates (DataPage), + deferred/streamed data (DeferredPage), error boundary with retry (UnstablePage). *@ + + + + + + + + + + + + +
+

Route failed to load

+

@err.Exception.Message

+ +
+
+
+ + @*============================================================================*@ + @* Navigation control: leave guard (unsaved changes), history entry state, + awaited navigation outcomes, and the always-cancelling /blocked target. *@ + + + + + + + + + + + + + + +
This never renders - the guard always cancels.
+
+ + @*============================================================================*@ + @* 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 + +
+

Data loading & caching

+

Route Loader + StaleTime cache + revalidation + query updates. Meta: @(Meta?.GetOrDefault())

+
+ +@if (Info is null) +{ +

No loader data.

+} +else +{ +
+

Loader result

+
+
Loaded at
@Info.LoadedAt.ToString("HH:mm:ss.fff")
+
Was revalidation
@Info.WasRevalidation
+
Page (from query)
@Info.Page
+
+

+ 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>.

+
+ +
+

Summary (from the loader, blocking)

+

@Report?.Summary

+
+ +
+

Details (deferred)

+ @if (Report is not null) + { + + +

Crunching the slow numbers…

+
+ + + + +

Details unavailable: @ex.Message

+
+
+ } +
+ +@code { + [CascadingParameter] public BrouterRouteData? Data { get; set; } + + private DeferredReport? Report => Data?.GetOrDefault(); +} diff --git a/src/Brouter/InteralDemos/Core/Pages/DiscoveredPage.razor b/src/Brouter/InteralDemos/Core/Pages/DiscoveredPage.razor new file mode 100644 index 0000000000..baabbf3239 --- /dev/null +++ b/src/Brouter/InteralDemos/Core/Pages/DiscoveredPage.razor @@ -0,0 +1,21 @@ +@page "/discovered/{id:int}" + +
+

Discovered page /discovered/{id:int}

+

+ 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.

+
+ +
+

+ + Draft @(State.IsEditorDirty ? "(unsaved changes - navigation is blocked)" : "(clean)") +

+ +
+ +
+

+ 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.) +

+
+ +@code { + private string _draft = string.Empty; + private bool _confirmExternal; + + private void OnInput(ChangeEventArgs e) + { + _draft = e.Value?.ToString() ?? string.Empty; + State.IsEditorDirty = _draft.Length > 0; + } + + private void Discard() + { + _draft = string.Empty; + State.IsEditorDirty = false; + } + + private async Task ToggleExternal(ChangeEventArgs e) + { + _confirmExternal = e.Value is true; + await brouter.SetConfirmExternalNavigationAsync(_confirmExternal); + } +} diff --git a/src/Brouter/InteralDemos/Core/Pages/GalleryItemPage.razor b/src/Brouter/InteralDemos/Core/Pages/GalleryItemPage.razor new file mode 100644 index 0000000000..759b221a3a --- /dev/null +++ b/src/Brouter/InteralDemos/Core/Pages/GalleryItemPage.razor @@ -0,0 +1,41 @@ +@* Detail page of the View Transitions gallery. The hero re-declares the clicked tile's + view-transition-name, completing the shared-element morph (see GalleryPage). *@ + +@if (Item is null) +{ +
+

No such gallery item.

+ Back to the gallery +
+} +else +{ + + +
+

@Item.Blurb

+
+ + +} + +@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.

+
+ +
+

Current entry's state

+

+ brouter.Location.HistoryState = + @(brouter.Location.HistoryState ?? "(null)") +

+
+ +
+

Push entries with state

+ + Link with HistoryState +
+ + +
+

+ 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. +

+
+ +@code { + private void PushStamped() => + brouter.Navigate("/history-state", historyState: $"pushed at {DateTime.Now:HH:mm:ss}"); +} diff --git a/src/Brouter/InteralDemos/Core/Pages/HomePage.razor b/src/Brouter/InteralDemos/Core/Pages/HomePage.razor index be38863b0c..bf29010a92 100644 --- a/src/Brouter/InteralDemos/Core/Pages/HomePage.razor +++ b/src/Brouter/InteralDemos/Core/Pages/HomePage.razor @@ -68,10 +68,58 @@ Guarded routes guard -

Routes that conditionally redirect via guards.

+

Routes that conditionally redirect via guards, cancel leaves, or block outright.

+ + +
+

+ + 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. +

+ +
+ +
+

+ + Navigation control + nav +

+

Awaited outcomes, history entry state, and the .NET 10 NotFound interop.

+ +
+ +
+

+ + Layout & state + layout +

+

Named outlets, pathless groups, keep-alive routes, and animated page changes via the View Transitions API.

+
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)

+ +

+ 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. +

+
+ +@code { + [CascadingParameter] public BrouterRouteData? Data { get; set; } + + private Task BreakItAgain() + { + State.UnstableShouldFail = true; + return brouter.RevalidateAsync().AsTask(); + } +} diff --git a/src/Brouter/InteralDemos/Core/Shared/Header.razor b/src/Brouter/InteralDemos/Core/Shared/Header.razor index c6f4b66091..beaa84843a 100644 --- a/src/Brouter/InteralDemos/Core/Shared/Header.razor +++ b/src/Brouter/InteralDemos/Core/Shared/Header.razor @@ -7,12 +7,17 @@
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 + + + + + + + + + +
other
+
+
+ +@if (ShowRenderPreloadLink) +{ + go +} + +@code { + [Parameter] public TimeSpan? StaleTime { get; set; } + [Parameter] public bool ShowRenderPreloadLink { get; set; } + + public int CachedLoaderRuns { get; private set; } + public int UncachedLoaderRuns { get; private set; } + public bool? LastWasPreload { get; private set; } + + private ValueTask CachedLoader(BrouterNavigationContext ctx) + { + CachedLoaderRuns++; + return ValueTask.FromResult($"cached-{CachedLoaderRuns}"); + } + + private ValueTask UncachedLoader(BrouterNavigationContext ctx) + { + UncachedLoaderRuns++; + LastWasPreload = ctx.IsPreload; + return ValueTask.FromResult($"uncached-{UncachedLoaderRuns}"); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/CachedView.razor b/src/Brouter/Tests/Bit.Brouter.Tests/CachedView.razor new file mode 100644 index 0000000000..be8a4f8860 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests/CachedView.razor @@ -0,0 +1,7 @@ +@* Renders the cascaded loader payload for LoaderCacheTests assertions. *@ + +
@(Data?.GetOrDefault())
+ +@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 + +
active:@IsActive deactivations:@_deactivations activations:@_activations
+ +@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 + + + + +
+ +
+ + +
a main
+ + +
a sidebar
+
+
+
+ +
b main
+
+ +
user @p["id"]
+ + +
sidebar for @p["id"]
+
+
+
+
+
+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NamedOutletTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/NamedOutletTests.cs new file mode 100644 index 0000000000..845920b63d --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests/NamedOutletTests.cs @@ -0,0 +1,61 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.Brouter.Tests; + +[TestClass] +public class NamedOutletTests : 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 void One_route_fills_both_the_primary_and_the_named_outlet() + { + var (cut, _) = RenderAt("http://localhost/dash/a"); + + cut.WaitForAssertion(() => + { + var main = cut.Find("[data-testid=main-outlet]"); + var side = cut.Find("[data-testid=side-outlet]"); + Assert.IsNotNull(main.QuerySelector("[data-testid=a-main]")); + Assert.IsNotNull(side.QuerySelector("[data-testid=a-side]")); + }); + } + + [TestMethod] + public void A_route_without_the_view_leaves_the_named_outlet_empty() + { + var (cut, brouter) = RenderAt("http://localhost/dash/a"); + cut.WaitForAssertion(() => cut.Find("[data-testid=a-side]")); + + cut.InvokeAsync(() => brouter.Navigate("/dash/b")); + + cut.WaitForAssertion(() => + { + Assert.IsNotNull(cut.Find("[data-testid=b-main]")); + // Switching to a view-less sibling clears the sidebar. + Assert.AreEqual(0, cut.FindAll("[data-testid=a-side]").Count); + Assert.AreEqual(string.Empty, cut.Find("[data-testid=side-outlet]").TextContent.Trim()); + }); + } + + [TestMethod] + public void Route_parameters_flow_into_named_view_fragments() + { + var (cut, _) = RenderAt("http://localhost/dash/u/42"); + + cut.WaitForAssertion(() => + { + Assert.IsTrue(cut.Find("[data-testid=u-main]").TextContent.Contains("42")); + Assert.IsTrue(cut.Find("[data-testid=u-side]").TextContent.Contains("42")); + }); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/NamedRouteHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/NamedRouteHost.razor index faec1a03bb..1ffa8fc427 100644 --- a/src/Brouter/Tests/Bit.Brouter.Tests/NamedRouteHost.razor +++ b/src/Brouter/Tests/Bit.Brouter.Tests/NamedRouteHost.razor @@ -1,9 +1,9 @@ @using Bit.Brouter - +
x
-
+
@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. *@ + + + + +
done
+
+
+
+ +@code { + private readonly TaskCompletionSource _childStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private async ValueTask LoadParent(BrouterNavigationContext ctx) + { + await _childStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), ctx.CancellationToken); + return "parent"; + } + + private ValueTask LoadChild(BrouterNavigationContext ctx) + { + _childStarted.TrySetResult(); + return ValueTask.FromResult("child"); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/PreventiveGuardHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/PreventiveGuardHost.razor new file mode 100644 index 0000000000..5530565e1a --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests/PreventiveGuardHost.razor @@ -0,0 +1,30 @@ +@using Bit.Brouter + + + +
home
+
+ +
cancel
+
+ +
redirect
+
+ +
login
+
+
+ +@code { + private ValueTask Cancel(BrouterNavigationContext ctx) + { + ctx.Cancel(); + return ValueTask.CompletedTask; + } + + private ValueTask Redirect(BrouterNavigationContext ctx) + { + ctx.Redirect("/login"); + return ValueTask.CompletedTask; + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/QueryBuilderTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests/QueryBuilderTests.cs new file mode 100644 index 0000000000..7cd5782d24 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests/QueryBuilderTests.cs @@ -0,0 +1,91 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.Brouter.Tests; + +[TestClass] +public class QueryBuilderTests : BunitTestContext +{ + private (IRenderedComponent Cut, IBrouter Brouter, FakeNavigationManager Nav) RenderAt(string url) + { + var nav = Services.GetRequiredService(); + nav.NavigateTo(url); + var cut = RenderComponent(); + return (cut, Services.GetRequiredService(), nav); + } + + [TestMethod] + public void Set_updates_one_parameter_and_preserves_the_rest() + { + var (cut, brouter, nav) = RenderAt("http://localhost/q?filter=red&sort=name&page=1"); + + cut.InvokeAsync(() => brouter.NavigateWithQuery(q => q.Set("page", 2))); + + cut.WaitForAssertion(() => + { + var uri = new Uri(nav.Uri); + Assert.AreEqual("/q", uri.AbsolutePath); + Assert.IsTrue(uri.Query.Contains("filter=red")); + Assert.IsTrue(uri.Query.Contains("sort=name")); + Assert.IsTrue(uri.Query.Contains("page=2")); + Assert.IsFalse(uri.Query.Contains("page=1")); + }); + } + + [TestMethod] + public void Null_value_and_Remove_delete_the_parameter() + { + var (cut, brouter, nav) = RenderAt("http://localhost/q?filter=red&page=3"); + + cut.InvokeAsync(() => brouter.NavigateWithQuery(q => q.Set("filter", null).Remove("page"))); + + cut.WaitForAssertion(() => + { + var uri = new Uri(nav.Uri); + Assert.AreEqual(string.Empty, uri.Query); + }); + } + + [TestMethod] + public void SetAll_emits_one_pair_per_value() + { + var (cut, brouter, nav) = RenderAt("http://localhost/q"); + + cut.InvokeAsync(() => brouter.NavigateWithQuery(q => q.SetAll("tag", ["a", "b"]))); + + cut.WaitForAssertion(() => + { + Assert.IsTrue(nav.Uri.EndsWith("/q?tag=a&tag=b", StringComparison.Ordinal)); + }); + } + + [TestMethod] + public void Replace_is_the_default_history_behavior() + { + var (cut, brouter, nav) = RenderAt("http://localhost/q?page=1"); + + cut.InvokeAsync(() => brouter.NavigateWithQuery(q => q.Set("page", 2))); + + cut.WaitForAssertion(() => + { + Assert.IsTrue(nav.Uri.Contains("page=2")); + Assert.IsTrue(nav.History.Last().Options.ReplaceHistoryEntry); + }); + } + + [TestMethod] + public void Values_are_formatted_invariantly() + { + var (cut, brouter, nav) = RenderAt("http://localhost/q"); + + cut.InvokeAsync(() => brouter.NavigateWithQuery(q => q.Set("price", 3.5).Set("active", true))); + + cut.WaitForAssertion(() => + { + Assert.IsTrue(nav.Uri.Contains("price=3.5")); + Assert.IsTrue(nav.Uri.Contains("active=true")); + }); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/QueryHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/QueryHost.razor index 7b2ade7cd7..0fe0393eb0 100644 --- a/src/Brouter/Tests/Bit.Brouter.Tests/QueryHost.razor +++ b/src/Brouter/Tests/Bit.Brouter.Tests/QueryHost.razor @@ -1,5 +1,5 @@ @* Hosts a single route bound to QueryConsumer so tests can drive query-string binding. *@ - + diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/RelativeLinkHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/RelativeLinkHost.razor new file mode 100644 index 0000000000..5a32c975db --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests/RelativeLinkHost.razor @@ -0,0 +1,16 @@ +@* Hosts a BrouterLink under a catch-all route so every navigation matches and fires OnNavigated + (which is what re-resolves a route-relative Href; unmatched navigations don't refresh links). *@ + +@using Bit.Brouter + + + +
page
+
+
+ +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. *@ + + + + +
done
+
+
+
+ +@code { + public List Events { get; } = []; + + private async ValueTask LoadParent(BrouterNavigationContext ctx) + { + Events.Add("parent:start"); + await Task.Delay(25); + Events.Add("parent:end"); + return "parent"; + } + + private ValueTask LoadChild(BrouterNavigationContext ctx) + { + Events.Add("child:start"); + return ValueTask.FromResult("child"); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests/SimpleHomeHost.razor b/src/Brouter/Tests/Bit.Brouter.Tests/SimpleHomeHost.razor index 76b8c03355..65ff01ff24 100644 --- a/src/Brouter/Tests/Bit.Brouter.Tests/SimpleHomeHost.razor +++ b/src/Brouter/Tests/Bit.Brouter.Tests/SimpleHomeHost.razor @@ -1,10 +1,10 @@ @* Two literal routes used by basic matching/trailing-slash tests. *@ - +
home
-
- + +
users
-
+
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")); + }); + } +}