Skip to content

Querystring building APIs for Blazor #34115

@SteveSandersonMS

Description

@SteveSandersonMS

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

  1. 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.
  2. 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

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

Metadata

Metadata

Assignees

Labels

DoneThis issue has been fixedapi-approvedAPI was approved in API review, it can be implementedarea-blazorIncludes: Blazor, Razor ComponentsenhancementThis issue represents an ask for new feature or an enhancement to an existing one

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions