Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/Brouter/Bit.Brouter.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
</Folder>
<Folder Name="/Tests/">
<Project Path="Tests/Bit.Brouter.Tests/Bit.Brouter.Tests.csproj" />
<Project Path="Tests/Bit.Brouter.Benchmarks/Bit.Brouter.Benchmarks.csproj" />
</Folder>
<Project Path="Bit.Brouter/Bit.Brouter.csproj" />
</Solution>
58 changes: 58 additions & 0 deletions src/Brouter/Bit.Brouter/Bit.Brouter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,62 @@
<InternalsVisibleTo Include="Bit.Brouter.Tests" PublicKey="00240000048000009400000006020000002400005253413100040000010001008D726FE8F1BED8CA2A003848640C872C6A5F2191C81EDA677B249A6E34BD9134EBA9FF7251582A5020CB3EEE83A61E7034CE712F5873D60F7C3C61F48592B256C560D7B3384E9889E4F81E4D406BC2B639915A4062D60751193AE66028D7BD4B9A3BF0823F1E38ABE5EADC3CD9615C6FF811974A9F6F89297DC2A722BF23D0BB" />
</ItemGroup>

<ItemGroup>
<Content Remove="tsconfig.json" />
<None Include="tsconfig.json" />
</ItemGroup>

<ItemGroup>
<TypeScriptFiles Include="Scripts\**\*.ts" />
</ItemGroup>

<!-- Restores npm packages then compiles the TypeScript into wwwroot. Both sub-targets are
incremental via their Inputs/Outputs, so this is a no-op when nothing changed. -->
<Target Name="BuildBrouterJavaScript" DependsOnTargets="InstallNodejsDependencies;BuildJavaScript" />

<!-- The output (wwwroot/bit-brouter.js) is a generated, git-ignored file. On a clean build it does
not exist when the default "wwwroot/**" Content glob is evaluated, so the static web asset
discovery in ResolveProjectStaticWebAssets would miss it and consumers get a 404 for
_content/Bit.Brouter/bit-brouter.js (this is what breaks the demo on a fresh checkout).

ResolveProjectStaticWebAssets runs BeforeTargets="AssignTargetPaths" and does NOT depend on
ResolveStaticWebAssetsInputs, so an ResolveStaticWebAssetsInputsDependsOn hook would never run
early enough. Hooking BeforeTargets="ResolveProjectStaticWebAssets" guarantees the file is
generated first (DependsOnTargets) and then explicitly added to @(Content) so discovery picks
it up. The Remove-before-Include keeps it to a single Content entry (avoiding a duplicate /
conflicting-target-path error) for builds where the glob already captured it. -->
<Target Name="IncludeBrouterJavaScriptAsStaticWebAsset"
BeforeTargets="ResolveProjectStaticWebAssets"
DependsOnTargets="BuildBrouterJavaScript">
<ItemGroup>
<Content Remove="wwwroot\bit-brouter.js" />
<Content Include="wwwroot\bit-brouter.js" />
</ItemGroup>
</Target>

<!-- In a multi-targeted build, produce the output exactly once in the outer (cross-targeting)
build, before the per-TFM inner builds are dispatched. This prevents several inner builds
from invoking tsc/esbuild concurrently against the same shared wwwroot output file.
The Condition is evaluated at execution time (when IsCrossTargetingBuild is set), so the
target only runs in the outer build; inner builds emit a harmless "DispatchToInnerBuilds
does not exist" message at detailed verbosity only. -->
<Target Name="BuildBrouterJavaScriptOnCrossTargeting"
BeforeTargets="DispatchToInnerBuilds"
Condition="'$(IsCrossTargetingBuild)' == 'true'"
DependsOnTargets="BuildBrouterJavaScript" />

<Target Name="InstallNodejsDependencies" Inputs="package.json" Outputs="node_modules\.package-lock.json">
<Exec Command="npm install" StandardOutputImportance="high" StandardErrorImportance="high" />
</Target>

<Target Name="BuildJavaScript" Inputs="@(TypeScriptFiles)" Outputs="wwwroot/bit-brouter.js">
<Exec Command="node_modules/.bin/tsc" StandardOutputImportance="high" StandardErrorImportance="high" />
<!-- Minify to a temp file and move it into place, rather than overwriting the input in-place.
An interrupted in-place minify could leave a half-written file that the Outputs check
above then treats as up-to-date, requiring a clean to recover. The Move is atomic enough
to avoid that. The esbuild "esm" format keeps the exports that the C# side imports as a module. -->
<Exec Condition=" '$(Configuration)' == 'Release' " Command="node_modules/.bin/esbuild wwwroot/bit-brouter.js --minify --format=esm --outfile=wwwroot/bit-brouter.min.js" StandardOutputImportance="high" StandardErrorImportance="high" />
<Move Condition=" '$(Configuration)' == 'Release' " SourceFiles="wwwroot/bit-brouter.min.js" DestinationFiles="wwwroot/bit-brouter.js" OverwriteReadOnlyFiles="true" />
</Target>
Comment thread
msynk marked this conversation as resolved.
Outdated

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Bit.Brouter;
/// <summary>
/// Declares a single route inside a <see cref="Brouter"/>.
/// </summary>
public class BrouterRoute : ComponentBase, IDisposable
public class Broute : ComponentBase, IDisposable
{
/// <summary>
/// The route path to match. Supports literal segments, parameter segments, constraints and wildcards.
Expand Down Expand Up @@ -41,33 +41,63 @@ public class BrouterRoute : ComponentBase, IDisposable

/// <summary>
/// Async data loader. Runs after the route matches and guards pass, before render.
/// The result is exposed via the cascading <c>RouteData</c> value.
/// The result is exposed to the rendered content as an unnamed cascading <see cref="BrouterRouteData"/>
/// (matched by type) with typed <c>Get&lt;T&gt;</c>/<c>TryGet&lt;T&gt;</c> accessors.
/// In a nested chain, loaders run sequentially root -> leaf by default (each parent's
/// loader completes before its child's starts); set <see cref="Brouter.ParallelLoaders"/>
/// to run independent loaders concurrently instead.
/// Inspired by React Router v6's <c>loader</c> and Angular's <c>Resolve</c>.
/// </summary>
[Parameter] public Func<BrouterNavigationContext, ValueTask<object?>>? Loader { get; set; }

/// <summary>Optional metadata. Exposed via the cascading <c>RouteMeta</c> value.</summary>
/// <summary>
/// Optional metadata. Exposed to the rendered content as an unnamed cascading <see cref="BrouterRouteMeta"/>
/// (matched by type) with typed <c>Get&lt;T&gt;</c>/<c>TryGet&lt;T&gt;</c> accessors.
/// </summary>
[Parameter] public object? Meta { get; set; }

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

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


[CascadingParameter(Name = "Brouter")] internal Brouter? Brouter { get; set; }
[CascadingParameter(Name = "ParentRoute")] internal BrouterRoute? Parent { get; set; }
[CascadingParameter(Name = "ParentRoute")] internal Broute? Parent { get; set; }
[CascadingParameter(Name = "RouteParameters")] internal BrouterRouteParameters? InheritedParameters { get; set; }
// 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<BrouterRoute> _children = [];
internal void AddChild(BrouterRoute route) => _children.Add(route);
internal void RemoveChild(BrouterRoute route) => _children.Remove(route);
private readonly List<Broute> _children = [];
internal void AddChild(Broute route) => _children.Add(route);
internal void RemoveChild(Broute route) => _children.Remove(route);

internal BrouterOutlet? Outlet { get; set; }

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
Expand Down Expand Up @@ -107,7 +137,11 @@ protected override void OnInitialized()
FullTemplate = $"{Parent.FullTemplate.TrimEnd('/')}/{Path.TrimStart('/')}";
}

RouteTemplate = BrouterTemplateParser.ParseTemplate(FullTemplate);
// Resolve constraints against this Brouter's DI-container-scoped registry (custom constraints
// registered via BrouterOptions.Constraints), falling back to 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);
Comment thread
msynk marked this conversation as resolved.

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

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

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

_renderer = new BrouterRouteRenderer(this);

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

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


internal bool Matched { get; set; }

Expand All @@ -156,17 +207,32 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)

internal void SetMatched()
{
// 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;

StateHasChanged();

Parent?.SetMatched();
if (Parent is null)
{
StateHasChanged();
}
else
{
Parent.SetMatched();
}
}

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

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

namespace Bit.Brouter;

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

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

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

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

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

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

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

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

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, _options);
return true;
}
catch (Exception ex) when (ex is JsonException
or ArgumentException
or System.IO.FileLoadException
or System.IO.FileNotFoundException
or BadImageFormatException
or TypeLoadException)
{
value = null;
return false;
}
}
}
Loading
Loading