Conversation
Persist pagination and sort state in URL query string parameters so QuickGrid works without interactivity. In SSR mode, buttons render as enhanced forms with antiforgery tokens instead of @OnClick handlers.
92bb4b5 to
1d9c7a7
Compare
There was a problem hiding this comment.
Pull request overview
Adds static SSR (non-interactive) support to QuickGrid pagination and column sorting by persisting state in the URL query string and rendering SSR-compatible form posts for interactions.
Changes:
- Introduces
QuickGrid<TGridItem>.QueryNameand URL query-string synchronization for sort state (including back/forward handling). - Updates
Paginatorand sortable column headers to render SSR-friendly<form method="post" data-enhance ...>interactions with antiforgery. - Adds E2E coverage for no-interactivity pagination/sorting scenarios plus supporting test pages/components.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/QuickGrid.razor.cs | Adds QueryName, query-string sort persistence, and navigation event handling/disposal. |
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/ColumnBase.razor | SSR branch for sortable headers using form posts; wires ColumnIndex from grid. |
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Columns/ColumnBase.razor.cs | Stores ColumnIndex and derives SSR form name from QueryName. |
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/Paginator.razor | SSR branch for pagination buttons using form posts + antiforgery. |
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/Paginator.razor.cs | Reads/writes current page to query string; listens to navigation changes. |
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Pagination/PaginationState.cs | Adds internal QueryName and clamps page index based on known bounds. |
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Infrastructure/QueryStringHelper.cs | Adds helper to read first matching query parameter via QueryStringEnumerable. |
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/Microsoft.AspNetCore.Components.QuickGrid.csproj | Includes shared QueryStringEnumerable.cs in compilation. |
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/src/PublicAPI.Unshipped.txt | Declares new/changed public API surface for shipping. |
| src/Components/QuickGrid/Microsoft.AspNetCore.Components.QuickGrid/test/GridRaceConditionTest.cs | Registers a test NavigationManager for new injection usage. |
| src/Components/test/E2ETest/Tests/QuickGridNoInteractivityTest.cs | New E2E tests validating SSR paging/sorting + URL persistence. |
| src/Components/test/E2ETest/Tests/QuickGridTest.cs | Adds interactive-mode dual-paginator coverage. |
| src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/QuickGrid/QuickGridComponent.razor | Adds SSR test page with multiple grids/paginators and distinct QueryNames. |
| src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj | Adds QuickGrid reference needed by the new test page. |
| src/Components/test/testassets/BasicTestApp/QuickGridTest/QuickGridDualPaginatorComponent.razor | Adds interactive test component for dual paginators. |
| src/Components/test/testassets/BasicTestApp/Index.razor | Adds menu entry for the new interactive test component. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
It is also a fix for #57289 |
) These tests were unquarantined in #65864 but are failing again across multiple PRs (#64964, #65871, #65451) in the Helix x64 Subset 2 job. Re-quarantining only the RazorRuntimeCompilationHostingStartupTest methods, not the RazorBuildTest ones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
) (#65881) These tests were unquarantined in #65864 but are failing again across multiple PRs (#64964, #65871, #65451) in the Helix x64 Subset 2 job. Re-quarantining only the RazorRuntimeCompilationHostingStartupTest methods, not the RazorBuildTest ones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
I think we're missing MVC-like approach that would avoid a breaking change. We can use <nav>
<form method="get" @onsubmit:preventDefault>
<input type="hidden" name="sort" value="Name" />
<input type="hidden" name="order" value="asc" />
<button class="col-title" type="submit"
@onclick="@(() => Grid.SortByColumnAsync(this))">
Name
</button>
</form>
</nav>The trade-off is more DOM elements (the To avoid hitting server in interactivity mode (which would spoil everything - users don't go interactive to send their forms to server on each "sort" click) - we can set SSR mode - No I prepared a comparison of PR's tests (current implementation vs form-based) to analyse if it can be fragile in some scenarios. 1.
|
|
The element change for sortable headers possibly fixes #66301. @dariatiurina Could you check how do the new sortable headers (with |
javiercn
left a comment
There was a problem hiding this comment.
Looks good. Some small comments, but we can do that in a follow up if needed
| <a class="go-first" href="@(CanGoBack ? GetPageUrl(0) : null)" title="Go to first page" aria-label="Go to first page" aria-disabled="@(!CanGoBack ? "true" : "false")">«</a> | ||
| <a class="go-previous" href="@(CanGoBack ? GetPageUrl(State.CurrentPageIndex - 1) : null)" title="Go to previous page" aria-label="Go to previous page" aria-disabled="@(!CanGoBack ? "true" : "false")">‹</a> |
There was a problem hiding this comment.
Wrap the button inside an a tag instead here, but we need to think if we should do this differently as I mentioned
There was a problem hiding this comment.
Sorry, stale comment. We can't wrap button inside a. It's not valid HTML. Your change is the best we can do
There was a problem hiding this comment.
We might need to use an AppCompatSwitch here
| /// The parameter from which the page and sorting URL parameters are derived. The default value is an empty string, which results in query parameters named "page", "sort", and "order". If you provide a non-empty value, for example "products", | ||
| /// then the query parameters will be "products_page", "products_sort", and "products_order". This allows you to use multiple <see cref="QuickGrid{TGridItem}"/> components on the same page without their URL parameters conflicting with each other. | ||
| /// </summary> | ||
| [Parameter] public string QueryName { get; set; } = ""; |
There was a problem hiding this comment.
Nit: This probably deserves a more descriptive name, like Query(String)?ParameterNamePrefix or something like that. It's confusing because it's a prefix, not the actual name.
| <Reference Include="Microsoft.Extensions.Caching.Hybrid" /> | ||
| <Reference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" /> | ||
| <Reference Include="Microsoft.AspNetCore.Session" /> | ||
| <Reference Include="Microsoft.AspNetCore.Components.QuickGrid" /> |
There was a problem hiding this comment.
Is this reference needed? I would imagine that this is brought in already by the appropriate test app
There was a problem hiding this comment.
Ah, The two other files in this project that we re added should go into BasicTestApp, not TestServer
There was a problem hiding this comment.
@javiercn BasicTestApp cannot test SSR. That is reason why files are in TestServer
@oroztocil I checked and the PR in the current version (links instead of buttons) fixes the issue. |
2206ef5 to
df8c410
Compare
QuickGrid SSR support
Description
QuickGrid'sPaginatorand column sorting currently require interactivity because they rely on@onclickevent handlers and in-memory state, which don't work with theStaticHtmlRenderer. Page index and sort state are stored in memory, so they're lost between SSR requests and can't be shared via URL.This PR adds URL-driven sorting and pagination to
QuickGridso that both features work without interactivity (SSR) by persisting state in the URL query string using regular<a>links. The feature is controlled by anAppContextswitch and is enabled by default.Problem
There were three main blockers preventing
QuickGridfrom working fully in SSR:@onclickis not supported in SSR — TheStaticHtmlRendererdoes not render event handler attributes like@onclick, so the pagination buttons and sort column headers had no effect.PaginationStatestores the current page index in memory, which is lost between SSR requests. Users also cannot share URLs pointing to a specific page.QuickGrid, which is also lost between SSR requests.Considered solutions
Session/TempData— Persist state server-side, keeping public APIs unchanged. Discarded, because it still doesn't allow URL sharing between users.?page=N,?sort=ColumnTitle,?order=asc/desc. This option was chosen because it provides a familiar UX, enables link sharing, and works with both SSR and interactive rendering.Feature flag
The URL-based navigation behavior is controlled by an
AppContextswitch:true) — sorting and pagination use<a>links with URL query parameters.falseto revert to the previous<button>-based behavior with@onclickhandlers.When the feature flag is disabled,
QuickGridandPaginatorrender and behave exactly as they did before this change, using buttons and in-memory state.Implemented changes
QuickGrid (
QuickGrid.razor.cs)QueryParameterNamePrefixparameter ([Parameter], defaults to"") — specifies a prefix for query string parameter names used to persist pagination and sort state. When empty, parameters are namedpage,sort, andorder. When set (e.g.,"products"), parameters becomeproducts_page,products_sort, andproducts_order. This allows multiple grids on the same page without URL parameter conflicts.NavigationManagerinjection — used to read the current URL and navigate with updated query parameters.QueryParameterValueSupplier— used to parse query string values from the current URL.OnInitialized— when the feature flag is enabled, parses the initial URL query string and subscribes toNavigationManager.LocationChangedto react to URL changes (e.g., browser back/forward).OnParametersSetAsync— propagates the page query parameter name to the associatedPaginationStateso thePaginatoruses the correct parameter name.ReadSortFromQueryString()— parses?sort=ColumnTitle&order=ascfrom the URL usingQueryParameterValueSupplierand caches the result for use during column collection. The sort column is identified by itsTitleproperty.GetSortUrl()/GetSortQueryStringUrl()— generates URLs with updated sort query parameters usingNavigationManager.GetUriWithQueryParameters().OnLocationChanged— responds to external URL changes (back/forward navigation) by re-reading sort state and refreshing data. Falls back to the default sort column/direction when sort parameters are removed from the URL._defaultSortColumn,_defaultSortAscending) so that removing sort params from the URL restores the original sort order.SortByColumnAsync— when the feature flag is enabled, navigates to the new sort URL instead of just callingStateHasChanged().DisposeAsync— updated to unsubscribe fromLocationChanged.Column sorting (
ColumnBase.razor/ColumnBase.razor.css)EnableUrlBasedQuickGridNavigationAndSortingis enabled and the column has a non-nullTitle, sortable column headers render as<a>elements with anhrefpointing to a URL with updated sort query parameters (viaGrid.GetSortUrl(this)). When the flag is disabled, the previous<button>with@onclickis rendered. Columns with a nullTitlefall back to a plain<div>.a.col-titleselectors alongside existingbutton.col-title, withtext-decoration: noneandcolor: inheritfor link styling consistency.Paginator rendering (
Paginator.razor/Paginator.razor.css)<a>elements withhrefpointing to the URL for the target page. Disabled state usesaria-disabled="true"andpointer-events: none. When disabled, the previous<button>with@onclickanddisabledattribute is rendered.nav aselectors alongside existingnav button, witharia-disabledselectors added alongside:disabled.Paginator logic (
Paginator.razor.cs)NavigationManagerinjection — used to generate page URLs viaGetUriWithQueryParameter().QueryParameterValueSupplier— used to parse the current page from the URL query string.OnInitialized— when the feature flag is enabled, subscribes toNavigationManager.LocationChanged.OnParametersSet→OnParametersSetAsync— changed to async to support reading the query string on first render and setting the initial page index (when the feature flag is enabled).ReadPageIndexFromQueryString()— parses the 1-based page number from the URL and converts it to a 0-based index.GetPageUrl()— generates URLs for pagination links. Page 1 omits the query parameter (clean URL); other pages use?page=N(1-based).OnLocationChanged— responds to external URL changes (back/forward navigation) by re-reading page state and updating accordingly.Dispose— unsubscribes fromLocationChangedwhen the feature flag is enabled.PaginationState (
PaginationState.cs)QueryNameinternal property — set byQuickGridso thePaginatorreads the correct query parameter name (e.g.,pageorproducts_page).Feature flag (
QuickGridFeatureFlags.cs)Microsoft.AspNetCore.Components.QuickGrid.EnableUrlBasedQuickGridNavigationAndSortingAppContextswitch. Defaults totrue(enabled) when the switch is not set.Shared infrastructure changes
QueryParameterValueSupplier.cs,QueryParameterNameComparer.cs,StringSegmentAccumulator.cs, andUrlValueConstraint.csmoved fromComponents/src/Routing/toShared/src/so they can be consumed by both the Components project and QuickGrid.QueryParameterValueSupplier.GetQueryString()— new public static method that extracts the query string portion from a URL. Previously a local static method inSupplyParameterFromQueryValueProvider, now shared.QueryStringEnumerable.cs— added as a shared source compile include in the QuickGrid project.Project references
Microsoft.AspNetCore.Components.csproj— added shared source includes for the moved routing files.Microsoft.AspNetCore.Components.QuickGrid.csproj— added shared source includes forQueryParameterValueSupplier,QueryParameterNameComparer,StringSegmentAccumulator,UrlValueConstraint, andQueryStringEnumerable.Public API changes (
PublicAPI.Unshipped.txt)Tests
QuickGridNoInteractivityTest— E2E tests for SSR (no interactivity) scenarios: paginator display, navigation, link-based pagination, disabled state, sorting by various types, multi-grid independence, dual paginators, out-of-range/invalid page handling, and direct URL loading with sort/page parameters.QuickGridInteractiveTest— E2E tests for interactive server rendering: anchor-based sorting and pagination with URL query parameter verification, dual paginators syncing, and multi-grid independence with prefixed query parameters.QuickGridInteractiveCompatTest— E2E tests for the opt-out path (feature flag disabled via--DisableUrlDrivenNavigation=true): verifies button-based sorting and pagination still work, and that the URL does not change during interactions.GridRaceConditionTest— updated to registerNavigationManagerin the test service provider.QuickGridComponent.razor(SSR page with multiple grids and paginators),QuickGridInteractive.razor(interactive server page), andQuickGridDualPaginatorComponent.razor(dual paginator test component).Breaking change
When the feature flag is enabled (the default), sort column headers and paginator buttons change from
<button>elements to<a>(link) elements. Any custom CSS targetingbutton.col-titleornav buttonin the paginator will need to be updated to targeta.col-titleornav arespectively. To revert to the previous behavior, set theAppContextswitch tofalse:Additionally, when multiple
QuickGridcomponents are used on the same page, each one now must have a uniqueQueryParameterNamePrefixvalue. SinceQueryParameterNamePrefixdefaults to"", having two or more grids without explicitly setting different values will cause them to share the same query parameters (page,sort,order) for pagination and sorting, and interfere with each other. This applies to both SSR and interactive rendering.Before (worked implicitly):
After (requires unique
QueryParameterNamePrefixper grid):Shared
PaginationStatelimitationEach
QuickGridpropagates its page query parameter name to the associatedPaginationStateduringOnParametersSetAsync, so that the connectedPaginatoruses the same query parameter name. This means multipleQuickGridcomponents must not share the samePaginationStateinstance if they have differentQueryParameterNamePrefixvalues — the last grid to render will overwrite the query name on the shared state, causing thePaginatorto read from the wrong query parameter. Each grid should have its ownPaginationStateinstance.Usage example
Fixes #51249
Fixes #57289
Fixes #66301