-
Notifications
You must be signed in to change notification settings - Fork 10.3k
Blazor QueryString enhancements - design proposal #33338
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
Comments
cc @dotnet/aspnet-blazor-eng |
See also: prototype implementation at https://github.com/dotnet/aspnetcore/compare/stevesa/querystring-prototype |
Would it also be possible to get / set multiple values for one querystring name? string[] Ids
{
get => Nav.GetQueryParameter<string[]>("id");
set => Nav.SetQueryParameter("id", value);
} |
@campersau We could support that, but it might be something we choose to leave as a possible future enhancement because you could always do it yourself by (for example) comma-separating the values or using some other encoding. |
The design looks great to me. I would get someone more familiar with routing (@javiercn / @captainsafia) to vet all of the use cases, but this feels very compelling. A couple of notes:
[Parameter][QueryParameter] public string Id { get; set; } Also open to other names.
|
This aspect of naming is awkward. There probably isn't an ideal solution. Other considerations:
My main concern would be around the discoverability through intellisense. If VS is likely to show both options, or the wrong option, in code completions, then we have a bad situation. But if in realistic/default cases it only prompts with the right one then I think it would be fine. Another option would be unsealing
I'm fine with keeping it out of scope initially. It's achievable in user code as an extension method. |
Thanks for writing this up. Looks sensible overall.
Is this worth addressing? My concern is that introduces an extra layer of complexity to the API. My inclination here is to treat the value the user sets as the source of truth and avoid the flag in the first iteration of the API.
Hmmm. It actually makes sense to me with the mental model that I've built of this. Nulling out a query param effectively unsets it.
Seems sensible to me. I'd imagine an initial implementation would have a:
Should this be |
For me, I would distill this in two levels:
For the first case, we need APIs to provide a way to parse the URL query parameters and to generate a new URL. In this vein, it might make sense to have a type for this, rather than APIs directly on NavigationManager. This is similar to how is done in ASP.NET Core and offers more flexibility:
For the second part, there are already ways to achieve this in navigation manager:
I'm concerned with us getting out of our depth to solve the async binding issue. I worry that we are tackling on a lot of complexity here to deal with quite an edge case scenario. I strongly suggest we avoid this given how narrow the application is (Binding to query string parameter within an OnInput event) and instead we provide a sample for people who need to do this. If you do So to summarize, here are my suggestions:
|
Thanks for the feedback. Also @javiercn and I spent some time talking through lots of details of this and the pros/cons of offering different levels of specialised built-in support. The whole thing is quite unsatisfying because all the nice solutions are invalidated by async-state-losing gotchas (on Blazor Server). There seems to be only two broad types of solution:
If we choose to go for the "solve it in the framework" design, the implementation breakdown looks a bit like this:
Add on 50% for contingency and further design changes/reviews, and this is a substantial investment (which still leaves us with a pretty specialized solution). @dotnet/aspnet-blazor-eng We need to decide if this meets the bar for 6.0 with this cost/benefit in mind. We could:
|
Also ping @DamianEdwards: don't know if you have time to read all 50 pages or whatever we have here, but you were interested in getting this feature and might have views on the scenarios/scope. |
Great write up! This was one of the very first issues I hit when writing my first Blazor Server app, as the main UI for the app was an open search text field. As pointed out, I wanted the search terms and other parameters to be preserved in the querystring as is typical in web search UX for the usual reasons. The code example @SteveSandersonMS links to as an example of what the user would have to do if we only do the first three items in the list he stated is effectively similar to what one has to do today, but vastly more simple and first-class looking. Of course ideally we would make interacting with the querystring as UX as first-class/elegant as other parameters but given the cost involved I'd take that suggestion as a great "round one" improvement for .NET 6 if we can, leaving open the possibility to further refine the experience with higher-level concepts in a future release. |
Could we reuse #25752 we already seem to have an abandoned PR with all the work from a community member. Could we build on top and acknowledge the contribution? |
Could we copy the design and implementation from https://github.com/dotnet/aspnetcore/blob/main/src/Http/WebUtilities/src/QueryHelpers.cs Simple tweaks here should suffice to remove the stuff that is specific to HttpAbstractions and make all get/set/replace, etc. operations possible. |
I'm fine if we do this internally inside RouteView, although I think there's no much difference with putting it on a Separate/Reusable component, which will benefit anyone that wants to use query strings independent of the context. |
Yes, I hope so, and don't see any reason why not.
Certainly many of the details from that code should be usable/copyable (e.g., all the stuff about encoding/decoding). As for whether updating a single query param should involve building a whole dictionary and then re-encoding everything back into a string, I guess it depends on how much we're interested in optimizing it. I can think of reasonable cases where you might be doing this a hundred times per component render (e.g., links in a grid), so perf is at least worth thinking about. Maybe the starting point should be an API shape that's reasonably agnostic to the implementation and then we can maybe start with something like the code there, and let it grow into something more optimal in the future if sufficient need arises.
Definitely agree that both options are good to have on the list. I split out the |
On Or maybe have the same old
void DecreasePageNumber()
{
var page = nav.GetQueryParameter("page",-1);
if(page < 0)
return;
if(page == 0)
nav.SetQueryParameter("page",null); // remove
else nav.SetQueryParameter("page",page - 1);
} While nullable get is still required when we need to distinguish logic between set as default and really missing On the generic ambiguity of nullable. I commonly design my get related API to always have 3 similar methods T GetQueryParameter<T>(string s) where T : class // cannot be used to get struct
T GetQueryParameter<T>(string s,in T defaultValue)
T? TryGetQueryParameter<T>(string s) where T : struct // cannot use class to get nullable |
All that remains now is #34115, so closing this. |
Uh oh!
There was an error while loading. Please reload this page.
Summary
Blazor should provide convenient mechanisms for working with querystrings. This is a popular community request: #22388
Motivation and goals
Developers often want to make some component states bookmarkable/linkable. Classic scenarios for this include:
Example of filtering/searching/navigating:
Another:
Example of more general input state being tracked in the URL (technically this uses
#
rather than querystring but is conceptually the same):To make this sort of thing work, developers need to be able to:
<select>
boxes, checkboxes, textboxes<a href="items?page=2">
)In all three cases, the developer usually also needs to perform async data access based on the query parameter (whether it's initial state, a change triggered by the user through the UI, or a change due to back/forwards navigation). Ideally a single place for "loading" logic would handle all three without duplication.
Why this is difficult today
Blazor doesn't currently provide any special support for working with querystrings. Technically you can do it, but it's not a streamlined experience:
NavigationManager
'sLocationChanged
event), but working with events is inconvenient (e.g., remembering to unsubscribe) and results in duplicated code, compared with reacting to navigation inOnParametersSetAsync
oninput
, but this involves inventing a whole non-obvious mechanism on your own. Unless you're very careful, you'll be likely to create potential keystroke-loss bugs because you have an async binding cycle.Do querystrings even matter?
You could argue that anything that can be done with querystrings could also be done with the existing route parameter mechanism. That's perhaps one of the reasons Blazor has managed to go so long without special support here. However,
@page
selection (the path) versus additional parameters (the query). This is an aesthetics thing.Web developers instinctively want to use querystrings when they are creating some kind of bookmarkable/linkable state within a page, especially when that state is user-constructed rather than just representing a choice among pre-existing entities. For example, it feels far better to have
/dotnet/aspnet/issues?search=Blazor+is+too+cool&sort=upvotes
than something like/dotnet/aspnet/issues/sortby-upvotes/milestone-notset/search-Blazor+is+too+cool
, even though the latter is technically possible.In scope
The key end-to-end scenario is having a component that tracks aspects of its state in querystring values, and that state automatically updates if the user uses back/forwards or opens a link where the URL contains pre-existing state.
However we can break this down to lower-level pieces (which collectively cover other end-to-end scenarios too):
@page
component. It's not clear we need to support this for arbitrary descendants that aren't even receiving parameters from the router.You might think that item 3 is redundant because it can be constructed from 1 and 2. However, unless we're careful about it, it's very likely that binding would exhibit gotchas or glitches due to the async nature of navigation events on Blazor Server. For example, after a keystroke you append a character, but because navigation hasn't happened yet, the textbox would re-render without your added character, reverting your keystroke temporarily. This is similar to the async
@bind
loop bug from the early days of Blazor Server (fixed long before the first public release). We need to be sure this works very conveniently with@bind
, without weird problems.Out of scope
Querystrings should explicitly not be involved in the router's selection of
@page
components./folder/somepage.aspx?a=1&b=2
matchesfolder/somepage.aspx
regardless of the querystring).products/123?view=details
vsproducts/123?view=reviews
to select between two different routable (@page
) components. You would need to have a single component matchingproducts/{id}
, and then inside that, use whatever logic you like (possibly based on querystring) to render different content or child components.I don't think we need to have any formatting/parsing options other than "invariant culture"
Risks / unknowns
Unknown: What's the most useful way to handle unparseable querystring values? If the developer is asking for a value called
page
of typeSystem.Int32
but the value isabc
, should we throw, or just treat it as "no value supplied"?Unknown: Should we treat empty-string values as being equivalent to unset/
null
, or should they be distinguishable? Generally it's good to preserve information, but it's also quite odd to see?filter=
in a URL, forcing developers to write their own special-case logic if they want empty-string values to disappear from the URL completely.Risk: Since this API looks and sounds like something from ASP.NET Core (server), some developers might mix up the two. TBH I don't really see how there's an actual problem here, since Blazor already has a client-side routing and parameter passing system and this is just a small extension of it.
I haven't been able to think of any other ways that this feature could be misused, cause security issues, or restrict us from other enhancements in the future. If you think of any, please post below!
Examples
Suggestion from #22388:
TBH I'm not convinced about some aspects of this:
[Parameter]
property will mislead people, since it's not legal to write to other[Parameter]
propertiesHowever, I do agree that
[FromQuery]
itself looks very usable and convenient as a way to receive values.Another suggestion from #22388:
I think the get/set APIs look decent but we could improve them with generics and automatic parsing/formatting. Also the
SetQuery
method needs some way to control append-vs-replace in the history stack.Another suggestion from #22388:
If we were going to support two-way binding on an inbound property like this then I agree this looks good. However this is not the design I'm going to recommend (e.g., because of the issues mentioned above).
Detailed design
I spent quite a bit of time trying to answer questions like "should you be able to control whether setting a query parameter causes a navigation event" and "how can you get updates without having to remember to unsubscribe" and "how can you control whether it invokes your
OnParametersSet
or not". Then of course there's the challenge of "how can@bind
avoid the async binding loop gotcha (losing keystrokes if you type too fast)". There are ugly solutions to all of these, but we want something that feels obvious and minimal.It turns out that these questions mostly just vanish if we completely separate reading/writing querystring values from receiving notifications. They should be two independent mechanisms.
Receiving querystring values, with update notifications
The natural way to receive initial querystring values, plus notifications if they change, is to treat them exactly like other route parameters. That is, they go onto a
[Parameter]
property viaSetParametersAsync
. Benefits:filter
criteria changes. YourOnParametersSetAsync
is already the right place to do this, and deals with things like "loading" states, asynchrony, error handling, etc.The router can naturally supply component parameters from the querystring, just like it supplies them from URL segments, as long as it knows which querystring parameters a selected component wants to receive:
The best way I can think of identifying which querystring parameters to pass is a new attribute. Consider handling URLs like
/category/123?page=4&filter=chicken%20nuggets
:The benefit of it actually being a
[Parameter]
property (rather than just using[FromQuery]
alone) is that the whole parameter-supplying mechanism will just work following its normal semantics about notifying-only-if-changed. All existing mechanisms already know how to deal with this.The benefit of having a separate
[FromQuery]
, rather than[Parameter(FromQuery = true)]
is that routing/URLs is an independent concept and isn't good to merge into the concept of parameters.Drawback: It looks as if you can put
[FromQuery]
on any parameter in any component, but it would not have any effect except on@page
components, because theRouter
isn't the thing supplying parameter values to descendant components. Possible mitigation: Compiler error if you use[FromQuery]
in a page without[RouteAttribute]
?Alternative considered
We could specify querystring parameters as part of the route pattern:
This has the nice benefit that it's obvious that only the
@page
component can receive the query values. However it also has lots of worse drawbacks:@page
from matching if the value isn't parseableGetting/setting query parameters procedurally
Besides receiving query parameters with update notifications via
[Parameter]
, any other arbitrary code that has access to theNavigationManager
should be able to get/set values:Get/set take a generic type
T
(usually inferred for "set" based on the value). Supported types are the same as for route constraints: string, bool, DateTime, decimal, double, float, Guid, int, long. Passing any other generic type results in aNotSupportedException
. Unknown: Should we supportNullable<...>
for the relevant subset of these too? I guess so.Get
Returns the value via culture-invariant parsing, if possible. If there's no value or it's unparseable, we return
default(T)
. We only support there being one value for a given query parameter, so we return the first.An interesting aspect of this is: what do we do if you call
SetQueryParameter
, and then before that navigation occurs, youGetQueryParameter
it back? Do we tell you the value you just set, or the value that's still in the URL? By default, I think we need to return the value you just set, even before it updates in the URL. This is essential for@bind
to work sensibly without losing keystrokes due to asynchrony.However for the edge case where someone really wants to see the value that's still in the URL:
Note that, on each incoming navigation event, we discard any pending values and from then on only reflect the actual URL state.
Drawbacks: This means the value returned by
GetQueryParameter
can be temporarily inconsistent withnavigationManager.Url
. It's for a good reason, and you can opt out. However some people will still get confused about it. A potentially bigger worry is if navigation could be cancelled and then theGetQueryParameter
value would remain inconsistent indefinitely. Currently I'm not aware this could happen but we may need to account for it in the future, resetting the temporary state if navigation is actually cancelled.Set
Does add-or-update, via culture-invariant formatting. If you pass
null
for a nullable typeT
, it's the same as "Delete".Setting a query parameter should ideally preserve the existing parameter ordering, appending at the end if it's a new key.
Setting a query parameter of course also causes an actual navigation. By default, we should append to the history stack, since that's the default for all the JS APIs. However people will often want to replace the existing entry:
Setting query parameters only triggers client-side navigation. We know there's never a need to force a full page load, because by definition, query parameters don't control page selection.
Who receives notification that you did this? The current
@page
component, but only if it has a corresponding[Parameter, FromQuery]
. That's the mechanism for opting into receiving notifications. There will also be scenarios where people have no need to receive a notification because they don't need to trigger anything inOnParametersSet
, as the code writing to the query parameter can also perform whatever actions construct the updated state.If you want to set multiple query parameters at once, use
navigationManager.NavigateTo
and do your own URL formatting.Delete
Needed only if
T
is non-nullable and you want to remove the value entirely. It triggers the same navigation and other effects asSetQueryParameter
. It also supportsreplace
.Two-way binding to a query parameter
The most explicit and low-tech way to do two-way binding to a query parameter is to define your own
get
/set
pair that uses theGetQueryParameter
/SetQueryParameter
APIs. Because of the behavior of "set" followed by "get" returning the most-recently-written value,@bind
will just do the right thing and there won't be any async binding loop bug:Declaring a
get
/set
pair like this is reasonably conventional for controlling what a@bind
does. I know some people will ask why they can't just@bind
directly to theFilter
parameter, but we can't do that (without introducing new magic) because the framework doesn't even know when you call the setter, so can't know to trigger navigation. Benefits of defining your ownget
/set
pair like this:replace
or do custom formatting conversionsFor people who don't want to define their own
get
/set
pair, we could offer a built-in API that hides away this detail:Nav.Query(name, replace=true)
would return areadonly struct
that's simply a way to callGetQueryParameter
/SetQueryParameter
so it can be used with@bind
. There can also be a generically-typed overload of it for non-string query parameters:Note that due to Razor syntax limitations, it's necessary to surround the expresion with
@(...)
because of the generic type parameter. People who find this too cumbersome can of course still define their ownget
/set
pair manually.We don't have to support
Nav.Query(name).Value
. We could just say you have to declare your ownget
/set
pair. However I suspect the community will gravitate towardsNav.Query(name).Value
as a more compact way of doing binding.Alternative considered
Instead of the
[Parameter, FromQuery]
property being of primitive types likestring
,int
, etc., we could define a custom type that specificially represents either a single query parameter or the whole dictionary of query parameters. This would then be usable with@bind
:I know this looks very appealing at first glance. It has fewer lines of code than the proposal above and seems more basic. I was pretty keen on going this way for a while, but it just doesn't line up with how diffing works. The core issue is: should
QueryAccessor<T>
be treated as deeply immutable for diffing purposes?.Value
property must be mutable, since that's also a core part of its purpose.QueryAccessor<T>
would force you to give up on not re-rendering when unrelated things happen in the layout.Also, this wouldn't provide any good way to bind to the query parameter value in a non-
@page
component, unlike the proposal above which does support that without any additional APIs.Also, syntactically it looks a lot like any component should be able to receive a
QueryAccessor<T>
, but in fact that wouldn't be possible for anything other than a@page
component. Changing this means reinventing something like cascading parameters but special-cased for routing. It would be better to let developers manage this flow in their own code, like they do for other route data. This is worse than[FromQuery]
because there's nothing clearly indicating the developer intends to get the value from routing as opposed to just passing through aQueryAccessor<T>
from a parent.In summary, I don't think
[Parameter] QueryAccessor<T>
is a good design today, but is something we could consider additively in the future if we choose to make the diffing system have some additional powers that would be overkill for this alone.Appendix: Async binding loop bugs
In a number of places above I've cited the hazard of "async binding loop bugs". Here's an explanation.
We must not recreate the async 2-way binding bug that long-ago affected Blazor Server (in alpha builds, before the first release). This happens if a component both reads and writes some state in an async binding cycle, e.g.:
The issue is if keystrokes arrive faster than this cycle completes, they can get lost:
abc
and sets it to be the initial state of a textboxd
, quickly followed bye
, in that textboxabcd
abcd
abcd
(losing thee
that was already typed)Even if the
abcde
event later gets processed and the UI updated, it's not OK to temporarily undo keystrokes in the UI. Also if they typef
while the textbox is temporarily showingabcd
, .NET will receive a message saying the textbox containsabcdf
and will permanently lose thee
.The
@bind
mechanism overcomes this by eliminating step 5. It specifically knows that a certain event handler represents an update from the value of a certain element, so pre-patches the "last rendered" tree to contain what we know must already be in the browser-side DOM. Then the diff algorithm sees it as not-a-change and hence there is no step 5.The text was updated successfully, but these errors were encountered: