-
-
Notifications
You must be signed in to change notification settings - Fork 266
Apply Brouter improvements (#12559) #12560
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
msynk
wants to merge
22
commits into
bitfoundation:develop
Choose a base branch
from
msynk:12559-brouter-improvements
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
5371612
apply Brouter improvements #12559
msynk 2bf38b5
apply type renames
msynk 62d8c17
make guards preventive
msynk ce28f77
add route discovery and prerender support
msynk 5f2aa76
add focus management
msynk eca2b61
improve scroll handling
msynk 9e73832
improve BrouterLink
msynk 243df1e
fix param
msynk f7a2e90
fix comment
msynk 209bd24
fix method
msynk 977f1ce
improve matching performance
msynk 920cc0a
improve route uniqueness check
msynk b599fb8
rename incorrect ones back
msynk 06eb3f4
rename types back
msynk a5252e2
fix global static constraint registry
msynk 791955d
add pending UI feature
msynk 70fd776
add navigation type to context
msynk c484772
resolve review comments
msynk 7a28e64
resolve review comments II
msynk 448ed58
apply further improvements
msynk 1038fe2
resolve review comments III
msynk f15fcb6
apply further improvements
msynk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Text.Json; | ||
|
|
||
| namespace Bit.Brouter; | ||
|
|
||
| /// <summary> | ||
| /// The serialized form of a single loader result carried across the SSR/prerender -> 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 -> interactive transition so a | ||
| /// loader that ran on the server isn't re-run (double-fetched) when the component becomes interactive. | ||
| /// Serialization is reflection/JSON based, hence trim/AOT-unsafe for arbitrary types; this is only reached | ||
| /// when the consumer opts in via <see cref="BrouterOptions.PersistLoaderState"/> and takes responsibility | ||
| /// for keeping their loader data types serializable and preserved. | ||
| /// </summary> | ||
| internal static class BroutePrerenderState | ||
| { | ||
| // Web defaults mirror the conventions Blazor itself uses for persisted component state and for | ||
| // JSON over the wire, so a single symmetric options instance is used for both directions. | ||
| private static readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web); | ||
|
|
||
| /// <summary> | ||
| /// Builds the persistence key for a loader in the matched chain. It is derived purely from the URL | ||
| /// (path + query) and the node's position in the matched chain, both of which are identical on the | ||
| /// prerender and interactive passes for the same navigation, so keys line up across the boundary. | ||
| /// </summary> | ||
| internal static string MakeKey(string path, string query, int chainIndex) => | ||
| $"Bit.Brouter|{path}|{query}|{chainIndex}"; | ||
|
|
||
| /// <summary>Captures a loader result into its persistable form.</summary> | ||
| [RequiresUnreferencedCode("Serializes an arbitrary loader result via System.Text.Json reflection.")] | ||
| [RequiresDynamicCode("Serializes an arbitrary loader result via System.Text.Json reflection.")] | ||
| internal static PersistedLoaderState Capture(object? value) | ||
| { | ||
| if (value is null) return new PersistedLoaderState { TypeName = null, Json = null }; | ||
|
|
||
| var type = value.GetType(); | ||
| return new PersistedLoaderState | ||
| { | ||
| TypeName = type.AssemblyQualifiedName, | ||
| Json = JsonSerializer.Serialize(value, type, _options), | ||
| }; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Rehydrates a previously-captured loader result. Returns <c>true</c> when a value (possibly null) | ||
| /// was restored and the loader should be skipped; <c>false</c> when restoration wasn't possible | ||
| /// (unknown type, malformed JSON) and the loader should run normally. | ||
| /// </summary> | ||
| [RequiresUnreferencedCode("Deserializes a loader result into its runtime type via System.Text.Json reflection.")] | ||
| [RequiresDynamicCode("Deserializes a loader result into its runtime type via System.Text.Json reflection.")] | ||
| internal static bool TryRestore(PersistedLoaderState? state, out object? value) | ||
| { | ||
| value = null; | ||
| if (state is null) return false; | ||
|
|
||
| // A persisted null result is still a decision the loader made: honor it and skip re-running. | ||
| if (string.IsNullOrEmpty(state.TypeName) || state.Json is null) return true; | ||
|
|
||
| var type = Type.GetType(state.TypeName, throwOnError: false); | ||
| if (type is null) return false; // type not available here; fall back to running the loader | ||
|
|
||
| try | ||
| { | ||
| value = JsonSerializer.Deserialize(state.Json, type, _options); | ||
| return true; | ||
| } | ||
| catch (JsonException) | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
|
msynk marked this conversation as resolved.
Outdated
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Reflection; | ||
|
|
||
| namespace Bit.Brouter; | ||
|
|
||
| /// <summary> | ||
| /// Discovers attribute-routed components (<c>@page</c> / <c>[Route]</c>) across one or more assemblies, | ||
| /// translating each declared route template into the form Bit.Brouter matches against. This is what lets | ||
| /// routes live colocated with their pages instead of being hand-declared as one big <see cref="Broute"/> | ||
| /// tree, mirroring the built-in <c>Router.AppAssembly</c> / <c>AdditionalAssemblies</c> model (including | ||
| /// lazily-loaded assemblies added at runtime). | ||
| /// </summary> | ||
| internal static class BrouteScanner | ||
| { | ||
| /// <summary>A single route discovered from a <c>[Route]</c> attribute on a routable component.</summary> | ||
| internal readonly record struct DiscoveredRoute( | ||
| string Template, | ||
| [property: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type ComponentType); | ||
|
|
||
| /// <summary> | ||
| /// Scans <paramref name="appAssembly"/> and <paramref name="additionalAssemblies"/> for public or | ||
| /// internal component types annotated with one or more <see cref="RouteAttribute"/> and returns the | ||
| /// discovered routes. Each <c>[Route]</c> on a component yields one entry, so a component with several | ||
| /// route attributes contributes several routes. Duplicate assemblies are scanned only once. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// This reflects over every type in the given assemblies, so it is inherently trim-unsafe: the | ||
| /// consumer is responsible for keeping their routable components (and the parameter types those | ||
| /// components bind) preserved when trimming, exactly as the built-in Blazor Router requires. | ||
| /// </remarks> | ||
| [RequiresUnreferencedCode( | ||
| "Attribute-route discovery reflects over all types in the supplied assemblies to find components " + | ||
| "annotated with [Route]/@page. Ensure routable components are preserved when trimming.")] | ||
| internal static IReadOnlyList<DiscoveredRoute> Discover(Assembly? appAssembly, IReadOnlyList<Assembly>? additionalAssemblies) | ||
| { | ||
| // Preserve declaration intent: scan the app assembly first so, on an otherwise-identical | ||
| // template tie, an app-level page is registered before one contributed by a referenced | ||
| // library (registration order is the final tie-breaker in Brouter.SelectWinner). | ||
| var seen = new HashSet<Assembly>(); | ||
| var results = new List<DiscoveredRoute>(); | ||
|
|
||
| ScanAssembly(appAssembly, seen, results); | ||
| if (additionalAssemblies is not null) | ||
| { | ||
| for (int i = 0; i < additionalAssemblies.Count; i++) | ||
| { | ||
| ScanAssembly(additionalAssemblies[i], seen, results); | ||
| } | ||
| } | ||
|
|
||
| return results; | ||
| } | ||
|
|
||
| [RequiresUnreferencedCode("See Discover.")] | ||
| private static void ScanAssembly(Assembly? assembly, HashSet<Assembly> seen, List<DiscoveredRoute> results) | ||
| { | ||
| if (assembly is null || seen.Add(assembly) is false) return; | ||
|
|
||
| // Use GetTypes (not GetExportedTypes): Razor components can be generated as internal, and the | ||
| // built-in Router discovers those too. A partially-loadable assembly throws | ||
| // ReflectionTypeLoadException but still exposes the types that did load via ex.Types. | ||
| Type?[] types; | ||
| try | ||
| { | ||
| types = assembly.GetTypes(); | ||
| } | ||
| catch (ReflectionTypeLoadException ex) | ||
| { | ||
| types = ex.Types; | ||
| } | ||
|
|
||
| foreach (var type in types) | ||
| { | ||
| if (type is null || type.IsClass is false || type.IsAbstract) continue; | ||
| if (typeof(IComponent).IsAssignableFrom(type) is false) continue; | ||
|
|
||
| // inherit: false — a base component's [Route] should not silently spawn routes for every | ||
| // derived component. This matches the built-in Router, which reads route attributes declared | ||
| // directly on the routable type. | ||
| var routeAttributes = type.GetCustomAttributes(typeof(RouteAttribute), inherit: false); | ||
| if (routeAttributes.Length == 0) continue; | ||
|
|
||
| foreach (var attribute in routeAttributes) | ||
| { | ||
| var template = ((RouteAttribute)attribute).Template; | ||
| results.Add(new DiscoveredRoute(NormalizeTemplate(template), type)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Translates an ASP.NET Core route template into Brouter's template dialect. The only structural | ||
| /// difference is catch-all syntax: ASP.NET Core accepts the single-star form <c>{*rest}</c> while | ||
| /// Brouter's parser expects the double-star form <c>{**rest}</c>. Everything else (literals, | ||
| /// <c>{id:int}</c> constraints, optional <c>{id?}</c> parameters) is already compatible. | ||
| /// </summary> | ||
| private static string NormalizeTemplate(string? template) | ||
| { | ||
| if (string.IsNullOrEmpty(template)) return "/"; | ||
|
|
||
| // Fast path: no single-star catch-all to rewrite. | ||
| if (template.IndexOf("{*", StringComparison.Ordinal) < 0) return template; | ||
|
|
||
| var segments = template.Split('/'); | ||
| for (int i = 0; i < segments.Length; i++) | ||
| { | ||
| var segment = segments[i]; | ||
| if (segment.StartsWith("{*", StringComparison.Ordinal) && | ||
| segment.StartsWith("{**", StringComparison.Ordinal) is false) | ||
| { | ||
| segments[i] = "{**" + segment[2..]; | ||
| } | ||
| } | ||
|
|
||
| return string.Join('/', segments); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.