NAS-143108 / 27.0.0-BETA.1 / Keep the snapshot list's page and selection across reloads and page turns - #14057
Conversation
Selecting a checkbox on page 2+ of Datasets -> Snapshots snapped the table back to page 1. The line that did it went with the tn-table migration -- the old checkbox column re-ran the search filter on every tick, and `setFilter` resets to the first page -- but two defects behind the same symptom survived it: - Every store emission re-ran the filter, so a periodic snapshot task firing was enough to send the user back to page 1 mid-task. `setFilter` now takes `keepPage`, and a reload nobody asked for re-runs the same query without moving them. - The pager's echo guard aliased the provider's own pagination object, so the provider resetting to page 1 in place read back as the pager's own push: the label stayed on page 2 over page 1's rows. The reported expectation also asks for selections from both pages to be retained, which the migration regressed -- tn-table cleared its selection whenever `dataSource` changed, and the previous UI had held the flag on the row objects themselves. tn-table gains a `selectionKey` input: with one set the selection is tracked by key, survives paging and a reload that rebuilt its rows, and select-all stays scoped to the visible page. Snapshots key on `name`. Needs @truenas/ui-components with `selectionKey`, `clearSelection()` and the pager echo-guard fix: the new tests and the AOT build fail against 0.7.2 until that release lands and the pin here is bumped. Claude-Session: https://claude.ai/code/session_01MnsWWxBSkRM6QrgbK74fmG
|
Five findings: five LOW. Nothing here blocks the merge. 🎉 The core of this is genuinely well done: What's left is all small. The one worth a second look is On the selection prune: In The remaining two are cosmetic: the One housekeeping note, not a finding: the description still says the pin moves |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #14057 +/- ##
==========================================
+ Coverage 88.76% 88.80% +0.03%
==========================================
Files 1894 1902 +8
Lines 71463 71630 +167
Branches 9331 9344 +13
==========================================
+ Hits 63434 63608 +174
+ Misses 8029 8022 -7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- Bump @truenas/ui-components to ~0.7.5, the release carrying `selectionKey`, `clearSelection()` and the pager echo-guard fix the branch needs. `yarn build:prod` now type-checks the `[selectionKey]` binding. - `BaseDataProvider.setFilter` clamps `pageNumber` into range under `keepPage`, before the rows are sliced, so a reload that shrinks the list below the current page can't serve an empty page. The pager corrects itself too, but only while it is mounted -- this template renders it inside the `@else` of the empty-state check, so it would go with the table. - Prune the selection where the snapshot list is rebuilt, not only in `onSelectionChange`. A snapshot selected on a page the user has since left and destroyed elsewhere changes nothing about the visible selection, so nothing guarantees tn-table emits for it; dropping it in `setSnapshots` makes the invariant hold whoever emits. `setSnapshots` is now the only writer of `snapshots`, so the name index can't drift from it either. Claude-Session: https://claude.ai/code/session_01MnsWWxBSkRM6QrgbK74fmG
Conflicts were package.json and yarn.lock, both on the @truenas/ui-components pin. Kept this branch's ~0.7.5 over master's ~0.7.4: 0.7.5 is the release carrying `selectionKey` and `clearSelection()`, which this branch's snapshot list binds and would fail AOT without. Claude-Session: https://claude.ai/code/session_01MnsWWxBSkRM6QrgbK74fmG
The dataset route applied the exact-dataset filter, then re-ran with the name filter when it matched nothing. Under `keepPage` that discarded pass was not free: it clamped the page against its own empty result, in place, so the surviving pass started from page 1. On /datasets/snapshots/tank, where tank holds no snapshots of its own but its children hold hundreds, a periodic snapshot task firing still sent the user back to page 1 -- the reported symptom, on the one route the paging tests never reached. Decide the branch up front instead, mirroring what the exact filter would match (filterTableRows lowercases both sides). One setFilter call, no clamp against a result nobody keeps, and no empty page pushed onto currentPage$. `snapshots` is now private so the compiler holds the invariant the comment claimed: setSnapshots is the only writer, and it keeps the name index and the selection in step with the list. The specs that assigned the field drive the store selector instead, which is how the paging suite already worked. Claude-Session: https://claude.ai/code/session_01MnsWWxBSkRM6QrgbK74fmG
Resolved in favour of the `expansionKey` fix: 0.7.6 keys `tn-table` expansion, so master's `lastRows`/`awaitingTableReset` reload branches — which waited for a reset the table no longer performs — are dropped as their own TODO asked. Claude-Session: https://claude.ai/code/session_01YWb7ZvZPxunLPDKRaBnce9
|
This PR has been merged and conversations have been locked. |
Note
This is not the fix for the bug as reported — that one is #14056, against
stable/26. On master the line that snapped the page back went with the tn-table migration, and the user confirmed master behaves correctly. What follows is a set of defects found while chasing it that are still live on master. Splitting these onto their own ticket is fine by me if you'd rather; opening it here to avoid losing the work.What this PR fixes
Two live regressions on master, both in Datasets → Snapshots:
1. A background reload throws the user back to page 1.
getSnapshots()re-ran the search filter on every store emission, andsetFilterunconditionally reset pagination — so a periodic snapshot task firing was enough to lose the user's place mid-task.setFilternow takeskeepPage, and the reload path passes it; a query the user actually typed still starts at page 1.2. Selecting rows across pages doesn't work. The ticket's expected result asks for "selections from both pages retained". The pre-migration UI held a
selectedflag on the row objects, so it did; tn-table clears its selection on anydataSourcechange, so the component was explicitly clearing on every emission and the selection died on a page turn or a reload.tn-tablenow takes[selectionKey], snapshots key onname, and the explicit clear is gone.Each fix opens a hole that this PR also closes, so neither lands half-done:
3. Keyed selection can outlive the snapshot. Retaining a selection across reloads means a snapshot selected on one page and destroyed elsewhere stays selected — the batch toolbar would offer to delete a snapshot that no longer exists. The selection is pruned against the store's list wherever that list is rebuilt, plus on the live
selectionChangepath.doBatchDeletealso moves toclearSelection();selection.clear()only drops the rows on screen and would let off-screen picks come back on the next reconcile.4.
keepPagecan hold a page that no longer exists. Skipping the reset means a reload that shrinks the list below the current page would slice past the end.setFilterclampspageNumberto the last page with rows before slicing.Not fixed here
The pager echo-guard bug —
pushToProvider()handed the provider the same object it kept aslastPushedPagination, so a provider that re-paginates in place mutated the pager's own guard and the label sat on "51 – 100" over page 1's rows — is a library fix, shipped in 0.7.5. It's consumed here by the pin bump, not present in this diff.Library dependency
Shipped in
@truenas/ui-components0.7.5; the pin here moves~0.7.4 → ~0.7.5(master, now merged in, had already taken it to~0.7.4).tn-tablegainsselectionKey— selection tracked by key, survivingdataSourcechanges, withselectionChangecarrying rows from pages that aren't on screen. Select-all andisAllSelectedstay scoped to the visible page, so the header never claims rows the user can't see. NewclearSelection()also drops the retained rows; plainselection.clear()would let them come back on the next reconcile.tn-table-pagerpushes a copy to the provider.SelectionAcrossPagesstory. Library suite green (4528), lint clean.Changed here
base-data-provider.tssetFilter(filter, { keepPage })+clampPaginationToLastPage()async-data-provider.tssnapshot-list.component.{ts,html}[selectionKey],setSnapshots()as the single writer,clearSelection(),keepPageon the reload pathpackage.json/yarn.lockkeepPagedefaults tofalse, so the other ~30setFiltercall sites are untouched.Review round
The clamp, and a correction. The review asked for the clamp on the grounds that this template renders
<tn-table-pager>inside the@elseof the empty-state check, so an empty page would unmount the pager and strand the user. That isn't reachable: the 0.7.5 pager self-corrects insyncFromProviderwhencurrentPage() > totalPages(), synchronously inside thecurrentPage$subscription, before change detection can unmount it. The clamp stays anyway — it belongs next to the flag, it makeskeepPagecorrect for a provider with no pager bound, and it removes the transient empty emission. ThesetFilterdoc comment, which had credited the pager with the correction, was updated to match.The prune moved off
selectionChange. It previously ran only there, which made correctness depend on tn-table emitting for a row it never rendered — a snapshot selected on a page the user has since left, then destroyed elsewhere, changes nothing about the visible selection. It now runs insetSnapshots()as well, so the invariant holds whoever emits.snapshotshas one writer.setSnapshots()writes the list, thesnapshotNamesindex and the pruned selection together, so the index can't drift. Done without convertingsnapshotsto a signal — it's read frombuildSearchFilterand from the specs, and none of that needed to churn.Testing
Three paging tests plus two rewritten selection tests on the component, and three new
ArrayDataProvidertests coveringkeepPage, clamp-to-last-page and clamp-when-empty. The clamp is tested at the provider rather than through the component: the pager corrects itself too, so a component-level assertion passes either way and wouldn't catch a regression. The off-screen prune test was verified to fail with the fix reverted.16 snapshot-list specs and 9 array-data-provider specs pass, lint is clean, and
yarn build:prodexits 0 against the released pin — the[selectionKey]binding now type-checks under AOT.Verified on hardware
Against a real appliance with 120 snapshots on one dataset (page size 50), instrumenting the live
ArrayDataProvider:51 – 100 of 120, row unmoved, batch toolbar appears120 → 121, pager stays51 – 100, selection intact112 → 82so page 3 stops existing → lands on51 – 82 of 82, 32 rows, no empty state, pager still mountedkeepPage: truewas logged on every reload-drivensetFilter, and the page held in 5/5 controlled trials (single delete, three rapid deletes, one and two selected off-screen deletes, and the clamp run).One false alarm worth recording: the pager was once seen jumping
101 – 121→1 – 50. That issetSorting's page reset, reproducible by clicking any column header (101 – 112→1 – 50 of 112), and is pre-existing intended behaviour this PR does not touch — not the reload path.Known adjacent issue, not touched here
bootenv-list,container-list,docker-images-listandinstalled-apps-listcalltable.selection.clear(), which doesn't reset the table's selection count — the header checkbox can be left stuck indeterminate until the next reload corrects it.clearSelection()is the right call there now, but it's out of scope for this PR.https://claude.ai/code/session_01MnsWWxBSkRM6QrgbK74fmG