-
Notifications
You must be signed in to change notification settings - Fork 10.4k
Description
As part of #33338, I'm considering what (if anything) we should do to help people generate URLs with particular querystrings from Blazor components.
Scenarios
- During rendering, emitting (a lot) of links to different combinations of querystring parameters. For example, pagination links, or links in every cell/row of a grid.
- During event handling, navigating to a URL with a specific combination of querystring parameters
In both cases, it's worth thinking of these two sub-cases:
a. If you're navigating to a different @page
component, or if your component completely owns and controls the whole URL, then you want to discard any other query parameters.
b. If you're just updating query parameter(s) while staying on the same page, and unrelated components are also storing state in the querystring, you need to retain the unrelated parameters.
In other words, are you also retaining the path part of the URL, and are you retaining unrelated query params?
Sample for case 1:
Here we generate page links to the current component, retaining the sort
parameter. We may or may not have to retain unrelated parameters (that depends on whether we're in subcase a or b).
@page "/products/{categoryId:int}"
@for(var pageIndex = 1; pageIndex < MaxPage; pageIndex++)
{
<a href="@(GetUrl(pageIndex, Sort))">Page @pageIndex</a> |
}
@code {
[Parameter] public int CategoryId { get; set; }
[Parameter, SupplyParameterFromQueryString] public int? Page { get; set; }
[Parameter, SupplyParameterFromQueryString] public string Sort { get; set; }
private string GetUrl(int pageIndex, string sort)
=> // TODO: implement - see below
}
Sample for case 2:
Consider a <Wizard>
component that has a step
querystring parameter. It's not a @page
component as you embed it in a routable page.
It doesn't own the whole URL, so need to just trigger navigations to "current URL except with a modified parameter".
@inject NavigationManager Nav
<button @onclick="MovePrev">Next</button>
<button @onclick="MoveNext">Next</button>
@code {
[Parameter] public string[] StepIds { get; set; }
[Parameter, SupplyParameterFromQueryString(Name = "step")] public string CurrentStepId { get; set; }
private async Task MoveNext()
{
// Ignoring details of error handling, etc.
var currentStepIndex = StepIds.IndexOf(CurrentStepId);
var nextStepId = StepIds[currentStepIndex + 1];
// Imagine this API retains all the query params except the one you're updating
return Nav.UpdateQueryParameterAsync("step", nextStepId);
}
}
Possible designs
1. Do nothing
We could leave developers to figure something out on their own. In subcases (a), this isn't too hard, as developers can create a utility method that takes whatever combination of params they want and formats a string
:
private string GetUrl(int pageIndex, string sort)
=> $"products?page={pageIndex}&sort={Uri.EscapeDataString(sort)}";
// Or maybe, if you want to omit default values:
private string GetUrl(int? pageIndex, string sort)
{
var query = new StringBuilder();
if (pageIndex.HasValue && pageIndex.Value > 1)
{
var separator = query.Length > 0 ? "&" : "?";
query.AppendFormat("{0}page={1}", separator, page.Value);
}
if (!string.IsNullOrEmpty(sort))
{
var separator = query.Length > 0 ? "&" : "?";
query.AppendFormat("{0}sort={1}", separator, Uri.EscapeDataString(sort));
}
return $"products{query}";
}
However it's harder if you need to retain unrelated parameters. You end up needing to parse the existing querystring.
If you're on Blazor Server, you can use WebUtilities:
private string GetUrl(int? pageIndex, string sort)
{
var newParams = new Dictionary<string, string>();
if (pageIndex.HasValue && pageIndex.Value > 1)
{
newParams["page"] = pageIndex.Value;
}
if (!string.IsNullOrEmpty(sort))
{
newParams["sort"] = sort;
}
return QueryHelpers.AddQueryString(
NavigationManager.Url,
newParams);
}
Or you can use QueryBuilder
from HttpExtensions
:
private string GetUrl(int? pageIndex, string sort)
{
var existingValues = QueryHelpers.ParseQuery(Nav.Uri);
var qb = new QueryBuilder(existingValues);
qb.Add("page", page.GetValueOrDefault(1));
qb.Add("sort", sort);
return Nav.UrlWithQuery(qb); // This doesn't exist, but we could add it
}
If you're on Blazor WebAssembly, it's more messy because you don't have a reference to WebUtilities, and you can't even reference the current version as it's not a package. You'll probably end up referencing the 2.2.0 version, which is on NuGet.
Pros:
- Cheapest option
- Nothing new for developers to learn
Cons:
- You have to know how to format each type of parameter correctly (e.g., always use culture-invariant format), or you'll get nonfunctional URLs
- If you're emitting your own string directly,
- It's easy to forget escaping
- You can't retain unrelated params without complex code
- If you're using WebUtilities/HttpExtensions:
- Hard to reference this on WebAssembly
- Perf cost of building a new dictionary for each combination (actually, two dictionaries). Overall really quite allocatey.
Overall, I think this might be OK as a first step. We could wait to see what level of customer demand emerges for making this more built-in.
2. Dictionary-based API
We could expose a dictionary-based API, just like QueryHelpers
:
private string GetUrl(int? pageIndex, string sort)
=> BlazorQueryHelpers.AddQueryString(NavigationManager.Url, new
{
{ "page", pageIndex.GetValueOrDefault(1) },
{ "sort", sort },
});
// Or retain existing values:
private string GetUrl(int? pageIndex, string sort)
{
var dict = BlazorQueryHelpers.ParseQuery(NavigationManager.Url);
dict["page"] = pageIndex.GetValueOrDefault(1);
dict["sort"] = sort;
return BlazorQueryHelpers.AddQueryString(NavigationManager.Url, dict);
}
// Or see above for an example of omitting default values
Pros:
- No need to define different APIs for "add", "update", "remove", since that's all inside
Dictionary
already - Covers retaining existing values as well as creating whole new URLs
Cons:
- If you're on Server, it's weird that both
QueryHelpers
and this new thing are both available, when they basically do the same thing - Allocates a lot and is computationally inefficient to build these dictionaries
- Still doesn't deal with formatting values. Developer still has to know to call the culture-invariant formatters.
- Or we could have
Dictionary<string, object>
and do the formatting for them, but that's even more allocatey
- Or we could have
Generally I think this is fine for "navigating during an event handler" (scenario 2), but would be pretty bad for "emitting a lot of links during rendering" (scenario 1).
3. Fluent builder API
Thanks to various improvements in modern .NET, we could create an efficient fluent API for constructing or modifying querystrings that doesn't allocate at all until it writes out the final string
instance:
// Creating a new URL, discarding existing values:
private string GetUrl(int? pageIndex, string sort)
=> Nav.CreateUrl("/products")
.WithQuery("page", pageIndex.GetValueOrDefault(1))
.WithQuery("sort", sort);
// Or retain existing URL and overwrite/add specific params:
private string GetUrl(int? pageIndex, string sort)
=> Nav.CreateUrl()
.WithQuery("page", pageIndex.GetValueOrDefault(1))
.WithQuery("sort", sort);
Or, since it's so compact, use it directly during rendering logic:
@for (var pageIndex = 1; pageIndex <= MaxPages; pageIndex++)
{
<a href="@Nav.UrlWithQuery("page", pageIndex)">Page @pageIndex</a> |
}
Pros:
- Doesn't clash with existing APIs. Naturally sits on
NavigationManager
to let you update the existing URL. - No allocations except producing the final
string
(see notes below). Also no work to construct dictionaries. - Can automatically format each supported parameter type in correct culture-invariant format. Fully strongly-typed via overloads.
Cons:
- Largest implementation cost
- Should this go into ASP.NET Core too? Doesn't seem Blazor-specific.
- Needs a separate method to remove items (e.g.,
.Remove(name)
)
Overall, this seems like the gold-standard solution but it's not yet 100% clear that it warrants the implementation effort.
Implementation notes: I made a prototype of this. It avoids allocations by using a fixed
buffer on a mutable struct
and String.Create
, plus the allocation-free QueryStringEnumerable
added recently. It's fairly involved, but customers don't need to see or care how it works internally. In the prototype, you can add/overwrite/remove up to 3 values before it has to allocate an expandable buffer (which is also transparent to callers).