@quazardous/qdadm 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@
19
19
  "test": "vitest run",
20
20
  "test:watch": "vitest",
21
21
  "gen:i18n-defaults": "node scripts/gen-i18n-defaults.mjs",
22
- "type-check": "vue-tsc --noEmit",
22
+ "type-check": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.typetest.json",
23
23
  "lint": "eslint src/",
24
24
  "lint:fix": "eslint src/ --fix",
25
25
  "format": "prettier --write src/",
@@ -104,6 +104,12 @@ const props = defineProps({
104
104
  rows: { type: Number, default: 10 },
105
105
  rowsPerPageOptions: { type: Array as PropType<number[]>, default: () => [10, 50, 100] },
106
106
  totalRecords: { type: Number, default: 0 },
107
+ /**
108
+ * Row offset of the page being shown — what positions the paginator (#2145).
109
+ * Required in lazy mode: the table is handed one page of rows and cannot
110
+ * infer which page they are.
111
+ */
112
+ first: { type: Number, default: 0 },
107
113
  lazy: { type: Boolean, default: false },
108
114
 
109
115
  // Sorting
@@ -438,6 +444,7 @@ function getFilterClass(filter: FilterConfig): Record<string, boolean> {
438
444
  :rows="rows"
439
445
  :rowsPerPageOptions="rowsPerPageOptions"
440
446
  :totalRecords="totalRecords"
447
+ :first="first"
441
448
  :lazy="lazy"
442
449
  :sortField="sortField ?? undefined"
443
450
  :sortOrder="sortOrder"
@@ -30,10 +30,13 @@ export * from './useJsonSyntax'
30
30
  export {
31
31
  useListPage,
32
32
  PAGE_SIZE_OPTIONS,
33
+ FILTER_TYPES,
33
34
  type UseListPageOptions,
34
35
  type UseListPageReturn,
35
36
  type ColumnConfig,
36
37
  type FilterConfig,
38
+ type FilterOptions,
39
+ type FilterType,
37
40
  type ActionConfig as ListActionConfig,
38
41
  type ResolvedAction,
39
42
  type HeaderActionConfig,
@@ -7,9 +7,10 @@
7
7
  * useListPage composes it back in; behavior is unchanged.
8
8
  */
9
9
  import { ref, computed, type Ref, type ComputedRef } from 'vue'
10
- import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
11
10
  import { FilterQuery, type QueryOrchestratorLike } from '../query/FilterQuery'
12
- import type { FilterConfig, SearchConfig } from './useListPage.types'
11
+ import { FILTER_TYPES } from './useListPage.types'
12
+ import type { FilterConfig, FilterOptions, SearchConfig } from './useListPage.types'
13
+ import type { RouteStatePersister } from '../routeState'
13
14
  import {
14
15
  SMART_FILTER_THRESHOLD,
15
16
  clearSessionFilters,
@@ -29,14 +30,38 @@ export interface UseListFiltersDeps {
29
30
  items: Ref<unknown[]>
30
31
  /** Current page ref — filter changes reset it to 1. */
31
32
  page: Ref<number>
33
+ /** Rows per page — persisted alongside the rest (#2146). */
34
+ pageSize: Ref<number>
35
+ /** Active sort field, persisted alongside the rest (#2146). */
36
+ sortField: Ref<string | null>
37
+ /** Active sort direction. */
38
+ sortOrder: Ref<number>
39
+ /**
40
+ * Whether the sort is remembered at all.
41
+ *
42
+ * Its own flag rather than folded into `routeStateWrites`, because it has
43
+ * always been separately switchable and a seam refactor must not quietly
44
+ * take an option away.
45
+ */
46
+ persistSort: boolean
32
47
  /** Search query ref (shared with the search subsystem). */
33
48
  searchQuery: Ref<string>
34
- route: RouteLocationNormalizedLoaded
35
- router: Router
36
49
  /** Session-restored filter values (already stripped of _search). */
37
50
  savedFilters: Record<string, unknown> | null
38
51
  persistFilters: boolean
39
- syncUrlParams: boolean
52
+ /** Where this list's state is remembered (#2146). */
53
+ persister: RouteStatePersister | null
54
+ /** This list's namespace, so two lists on one route stop colliding. */
55
+ routeStateScope: string
56
+ /**
57
+ * Whether this list may WRITE its state back.
58
+ *
59
+ * Separate from having a persister because reading and writing were always
60
+ * separate here: `syncUrlParams: false` stopped the query string being
61
+ * written and never stopped it being read, so a hand-typed deep link still
62
+ * restored. Collapsing the two would have changed that quietly.
63
+ */
64
+ routeStateWrites: boolean
40
65
  autoLoadFilters: boolean
41
66
  filterSessionKey: string
42
67
  /** Entity filters registry (injected by the consuming app). */
@@ -51,7 +76,7 @@ export interface UseListFiltersReturn {
51
76
  filterValues: Ref<Record<string, unknown>>
52
77
  filters: ComputedRef<FilterConfig[]>
53
78
  hasActiveFilters: ComputedRef<boolean>
54
- addFilter: (name: string, filterConfig: Omit<FilterConfig, 'name'>) => void
79
+ addFilter: (name: string, filterConfig: FilterOptions) => void
55
80
  removeFilter: (name: string) => void
56
81
  setFilterValue: (name: string, value: unknown) => void
57
82
  updateFilters: (newValues: Record<string, unknown>) => void
@@ -74,11 +99,15 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
74
99
  items,
75
100
  page,
76
101
  searchQuery,
77
- route,
78
- router,
79
102
  savedFilters,
80
103
  persistFilters,
81
- syncUrlParams,
104
+ persister,
105
+ routeStateScope,
106
+ routeStateWrites,
107
+ pageSize,
108
+ sortField,
109
+ sortOrder,
110
+ persistSort,
82
111
  autoLoadFilters,
83
112
  filterSessionKey,
84
113
  entityFilters,
@@ -89,16 +118,21 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
89
118
  const filtersMap = ref<Map<string, FilterConfig>>(new Map())
90
119
  const filterValues = ref<Record<string, unknown>>(savedFilters || {})
91
120
 
92
- /** Names the URL sync already owns — a filter cannot have them (#2113). */
121
+ /** Names the route state already owns — a filter cannot have them (#2113). */
93
122
  const RESERVED_QUERY_KEYS = new Set(['page', 'search'])
94
123
  const warnedReserved = new Set<string>()
95
124
 
96
125
  /**
97
- * A filter named `page` or `search` silently overwrites the query key of
126
+ * A filter named `page` or `search` silently overwrites the stored key of
98
127
  * the same name, and is overwritten back on restore. `search` has behaved
99
128
  * this way since the URL sync existed and nobody ever reported it, which
100
- * says how quiet the failure is; `page` joins the reserved set now that the
101
- * page number lives in the URL too.
129
+ * says how quiet the failure is; `page` joined it once the page number
130
+ * started being remembered too.
131
+ *
132
+ * SCOPING DID NOT FIX THIS. Prefixing keys by entity separates this list
133
+ * from other lists; it does not separate the list from qdadm's own two
134
+ * keys, which live in the same scope it does — `offers.page` is written by
135
+ * the pager whatever a filter called `page` wants.
102
136
  *
103
137
  * We name what happens INSTEAD of what was asked, rather than dropping the
104
138
  * filter — renaming someone's filter behind their back would be a worse
@@ -106,20 +140,53 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
106
140
  */
107
141
  function warnIfReservedName(name: string): void {
108
142
  if (!RESERVED_QUERY_KEYS.has(name)) return
109
- if (!syncUrlParams) return
143
+ // Gated on writing, not on the URL specifically: the collision is with
144
+ // whatever medium this list persists to.
145
+ if (!routeStateWrites) return
110
146
  if (warnedReserved.has(name)) return
111
147
  warnedReserved.add(name)
112
148
  console.warn(
113
- `[qdadm] Filter "${name}" on "${entityName}" collides with the URL ` +
114
- `parameter of the same name. With syncUrlParams on, the list's own ` +
149
+ `[qdadm] Filter "${name}" on "${entityName}" collides with the key of ` +
150
+ `the same name in this list's persisted route state. The list's own ` +
115
151
  `${name === 'page' ? 'page number' : 'search query'} wins and this ` +
116
- `filter will not survive a reload. Rename the filter, or set ` +
117
- `syncUrlParams: false on this list.`
152
+ `filter will not survive a reload. Rename the filter, or turn the ` +
153
+ `persistence off on this list with syncUrlParams: false.`
154
+ )
155
+ }
156
+
157
+ const warnedTypes = new Set<string>()
158
+
159
+ /**
160
+ * A filter type qdadm cannot render (#2147).
161
+ *
162
+ * `ListPage` is binary — an autocomplete, or a `Select` for everything
163
+ * else, unknown types included. So an invented type does not fail; it
164
+ * renders as a dropdown with no options, reading "No available options",
165
+ * and the user concludes there is nothing to choose. Reported by a consumer
166
+ * whose two `type: 'text'` filters had never filtered anything.
167
+ *
168
+ * Warned HERE because this is the last place the filter's NAME is known.
169
+ * Three screens later there is only an empty control and no way back to
170
+ * the declaration that caused it.
171
+ */
172
+ function warnIfUnknownType(name: string, type: unknown): void {
173
+ if (type === undefined || type === null) return
174
+ if (FILTER_TYPES.includes(type as (typeof FILTER_TYPES)[number])) return
175
+ const key = `${name}:${String(type)}`
176
+ if (warnedTypes.has(key)) return
177
+ warnedTypes.add(key)
178
+ console.warn(
179
+ `[qdadm] Filter "${name}" on "${entityName}" has type "${String(type)}", ` +
180
+ `which qdadm cannot render. It will fall back to a dropdown, and ` +
181
+ `unless you also give it options that dropdown will be empty — ` +
182
+ `"No available options" — rather than the control you asked for. ` +
183
+ `Known types: ${FILTER_TYPES.join(', ')}.`
118
184
  )
119
185
  }
120
186
 
121
- function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
187
+ function addFilter(name: string, filterConfig: FilterOptions): void {
122
188
  warnIfReservedName(name)
189
+ warnIfUnknownType(name, filterConfig?.type)
123
190
  filtersMap.value.set(name, {
124
191
  name,
125
192
  type: 'select',
@@ -155,34 +222,37 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
155
222
  * for a detail view, so that coming back restores it.
156
223
  */
157
224
  function writeStateToUrl(): void {
158
- if (!syncUrlParams) return
159
-
160
- const query = { ...route.query } as Record<string, string>
161
-
162
- for (const [name, value] of Object.entries(filterValues.value)) {
163
- if (value !== null && value !== undefined && value !== '') {
164
- query[name] = String(value)
165
- } else {
166
- delete query[name]
167
- }
168
- }
169
-
170
- if (searchQuery.value) {
171
- query.search = searchQuery.value
172
- } else {
173
- delete query.search
174
- }
175
-
176
- // Page 1 is the default and stays implicit. Reserved keys are written
177
- // last so the list's own state wins a collision — the warning in
178
- // addFilter() names that consequence at declaration time.
179
- if (page.value > 1) {
180
- query.page = String(page.value)
181
- } else {
182
- delete query.page
183
- }
184
-
185
- router.replace({ query })
225
+ if (!persister || !routeStateWrites) return
226
+
227
+ // Page 1 is the default and stays implicit, exactly like an unset filter:
228
+ // the persister drops null/undefined/'' rather than storing them empty,
229
+ // so a pristine list leaves a clean link.
230
+ //
231
+ // `search` and `page` still share the namespace with the filter names —
232
+ // the scope separates this list from OTHER lists, not from qdadm's own
233
+ // keys — which is why addFilter() still warns about a filter called
234
+ // `page` or `search`.
235
+ persister.write(routeStateScope, {
236
+ ...filterValues.value,
237
+ search: searchQuery.value || null,
238
+ page: page.value > 1 ? page.value : null,
239
+ // Always written, never conditional on a default: rows-per-page is a
240
+ // choice somebody made, and the default is not "unset" — it is what
241
+ // they get back if we forget. The default composition routes it to a
242
+ // cookie, so it does not land in a shareable link.
243
+ pageSize: pageSize.value,
244
+ // Sort goes to the session under the default composition, so it does
245
+ // not reach a link either. `persistSort: false` writes null, which the
246
+ // persister removes — the option has always been able to turn this off
247
+ // and still can.
248
+ //
249
+ // The DIRECTION is stored only when there is a field to apply it to.
250
+ // An order on its own is not partial state, it is meaningless state,
251
+ // and storing it would leave `sortOrder: -1` sitting in every unsorted
252
+ // list's namespace.
253
+ sort: persistSort ? sortField.value : null,
254
+ sortOrder: persistSort && sortField.value ? sortOrder.value : null,
255
+ })
186
256
  }
187
257
 
188
258
  function onFiltersChanged(): void {
@@ -220,15 +290,7 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
220
290
  clearSessionFilters(filterSessionKey)
221
291
  }
222
292
  page.value = 1
223
- if (syncUrlParams) {
224
- const query = { ...route.query } as Record<string, string>
225
- for (const key of filtersMap.value.keys()) {
226
- delete query[key]
227
- }
228
- delete query.search
229
- delete query.page
230
- router.replace({ query })
231
- }
293
+ if (routeStateWrites) persister?.clear(routeStateScope)
232
294
  loadItems()
233
295
  }
234
296
 
@@ -472,23 +534,31 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
472
534
  }
473
535
 
474
536
  function restoreFilters(): void {
537
+ const stored = persister?.read(routeStateScope)
538
+ if (!stored) return
539
+
540
+ // Only keys this list declares as filters: the scope keeps other lists
541
+ // out, but a persister may still hand back something the app put there.
475
542
  for (const key of filtersMap.value.keys()) {
476
- if (route.query[key] !== undefined) {
477
- let value: unknown = route.query[key]
478
- if (value === 'true') value = true
479
- else if (value === 'false') value = false
480
- else if (value === 'null') value = null
481
- else if (!isNaN(Number(value)) && value !== '') value = Number(value)
482
- filterValues.value[key] = value
483
- }
543
+ if (stored[key] !== undefined) filterValues.value[key] = stored[key]
484
544
  }
485
- if (route.query.search) {
486
- searchQuery.value = route.query.search as string
545
+
546
+ // NOT `typeof === 'string'` (#2147). A term that survives a numeric round
547
+ // trip — an order id, an invoice, a reference — comes back from the URL
548
+ // as a number, and a string-only guard dropped it: the box came back
549
+ // empty and the list unfiltered, so a shared link showed the recipient
550
+ // something other than what the sender searched for.
551
+ //
552
+ // Safe to stringify because the persister no longer coerces lossily: a
553
+ // value that could not be written back as the same text was left as text.
554
+ if (stored.search !== null && stored.search !== undefined && stored.search !== '') {
555
+ searchQuery.value = String(stored.search)
487
556
  }
557
+
488
558
  // The page must be restored HERE, before the first loadItems(): restoring
489
559
  // it later would mean a first request for page 1 and a second for the
490
560
  // real one (#2113).
491
- const restoredPage = Number(route.query.page)
561
+ const restoredPage = Number(stored.page)
492
562
  if (Number.isInteger(restoredPage) && restoredPage > 0) {
493
563
  page.value = restoredPage
494
564
  }
@@ -29,6 +29,12 @@ import {
29
29
  type Ref,
30
30
  } from 'vue'
31
31
  import { useRouter, useRoute, type RouteLocationRaw } from 'vue-router'
32
+ import {
33
+ createDefaultRouteStatePersister,
34
+ resolveRouteStatePersister,
35
+ createRouteStatePersisterFactory,
36
+ type RouteStatePersister,
37
+ } from '../routeState'
32
38
  import { useConfirm } from 'primevue/useconfirm'
33
39
  import { useHooks } from './useHooks.js'
34
40
  import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
@@ -68,6 +74,8 @@ import type {
68
74
  export type {
69
75
  ColumnConfig,
70
76
  FilterConfig,
77
+ FilterOptions,
78
+ FilterType,
71
79
  ActionConfig,
72
80
  ResolvedAction,
73
81
  HeaderActionConfig,
@@ -86,11 +94,11 @@ export type {
86
94
  // Stateless utilities (cookies, session storage, formatters, constants).
87
95
  import {
88
96
  PAGE_SIZE_OPTIONS,
89
- getSavedPageSize,
97
+ restorePageSize,
90
98
  getSessionFilters,
91
- getSessionSort,
92
- setSessionSort,
93
- persistPageSize,
99
+ restoreSort,
100
+ clearSessionSort,
101
+ retireLegacyPageSizeCookie,
94
102
  } from './useListPage.utils'
95
103
  import { useActionRegistry } from './useActionRegistry'
96
104
  import { useListFilters } from './useListPage.filters'
@@ -101,6 +109,9 @@ import { createOrchestratorToast } from './useOrchestratorToast'
101
109
  // Re-export PAGE_SIZE_OPTIONS so existing consumers (qdadm/index.ts,
102
110
  // composables/index.ts) keep their import path.
103
111
  export { PAGE_SIZE_OPTIONS } from './useListPage.utils'
112
+ // A VALUE, not only a type: a consumer guard should read this list rather
113
+ // than copy it, so adding a type here never turns correct code red (#2147).
114
+ export { FILTER_TYPES } from './useListPage.types'
104
115
 
105
116
 
106
117
  /**
@@ -120,6 +131,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
120
131
  persistFilters = true,
121
132
  persistSort = true,
122
133
  syncUrlParams = true,
134
+ routeState = null,
123
135
  autoLoadFilters = true,
124
136
  onBeforeLoad = null,
125
137
  onAfterLoad = null,
@@ -215,6 +227,41 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
215
227
  // Session key for filter persistence (based on entity name)
216
228
  const filterSessionKey = entity || entityName
217
229
 
230
+ // App-wide route-state default (#2146). Null when the kernel said nothing,
231
+ // or when there is no kernel at all — which is the case in tests.
232
+ const kernelRouteState = inject<string | RouteStatePersister | null>('qdadmRouteState', null)
233
+
234
+ // Where this list remembers what it is showing (#2146).
235
+ //
236
+ // WHO DECIDES, most specific first: this list, then the entity (so every
237
+ // screen showing it agrees), then the kernel, then the default composition.
238
+ // Each level exists because somebody has to be able to say it at that level
239
+ // — a list is a screen's decision, an entity's medium is a modelling one,
240
+ // and the app-wide floor belongs in the bootstrap.
241
+ //
242
+ // Built HERE, before the state refs, because `pageSize` is restored from it
243
+ // and the table needs a row count from its very first render.
244
+ const routeStateScope = entity || entityName
245
+ const chosenRouteState = routeState ?? manager.routeState ?? kernelRouteState
246
+ const persister: RouteStatePersister = chosenRouteState
247
+ ? resolveRouteStatePersister(
248
+ chosenRouteState,
249
+ createRouteStatePersisterFactory({ router, route })
250
+ )
251
+ : createDefaultRouteStatePersister({ router, route })
252
+
253
+ // `syncUrlParams` governs WRITING, and only ever has: a list configured
254
+ // with `false` still restored from a query string typed by hand. Kept
255
+ // exactly, because moving this behind a seam must not change what any
256
+ // existing app does.
257
+ //
258
+ // Naming a persister is a deliberate choice about a medium, so it answers
259
+ // the URL flag rather than obeying it: `routeState` set means read AND
260
+ // write, whatever `syncUrlParams` says about the query string.
261
+ const routeStateWrites = chosenRouteState ? true : syncUrlParams
262
+
263
+ const storedRouteState = persister.read(routeStateScope)
264
+
218
265
  // Entity filters registry (optional, provided by consuming app)
219
266
  const entityFilters = inject<Record<string, { search?: SearchConfig; filters?: FilterConfig[] }>>(
220
267
  'qdadmEntityFilters',
@@ -238,15 +285,29 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
238
285
 
239
286
  // Pagination
240
287
  const page = ref(1)
241
- const pageSize = ref(getSavedPageSize(defaultPageSize))
288
+ // Rows per page comes through the seam now (#2146) — by default routed to
289
+ // the same cookie it has always used, because it is a comfort setting
290
+ // somebody chose once, not something a link should impose on its reader.
291
+ //
292
+ // The legacy bare-number cookie is read when the seam has nothing, so an
293
+ // existing user's choice survives the move; the first change retires it.
294
+ const pageSize = ref(restorePageSize(storedRouteState?.pageSize, defaultPageSize))
242
295
  const totalRecords = ref(0)
243
296
  const rowsPerPageOptions = PAGE_SIZE_OPTIONS
244
297
 
245
- // Sorting — restored from the per-entity session (#1218), defaultSort as
246
- // fallback; same persistence discipline as filters/pageSize.
247
- const savedSort = persistSort ? getSessionSort(filterSessionKey) : null
248
- const sortField = ref<string | null>(savedSort ? savedSort.field : defaultSort)
249
- const sortOrder = ref(savedSort ? savedSort.order : defaultSortOrder)
298
+ // Sorting — through the seam now (#2146), and still landing in the session
299
+ // by default: the composition routes `sort` back to `sessionStorage` on
300
+ // purpose, so an ordering keeps surviving a clean `/offers` the way it
301
+ // always has. Only the key changed, which is why the legacy entry is read
302
+ // when the seam has nothing.
303
+ const savedSort = persistSort
304
+ ? restoreSort(storedRouteState, filterSessionKey, {
305
+ field: defaultSort,
306
+ order: defaultSortOrder as 1 | -1,
307
+ })
308
+ : { field: defaultSort, order: defaultSortOrder as 1 | -1 }
309
+ const sortField = ref<string | null>(savedSort.field)
310
+ const sortOrder = ref<number>(savedSort.order)
250
311
 
251
312
  // Search
252
313
  const searchQuery = ref(savedSearch)
@@ -588,18 +649,38 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
588
649
  // ============ FILTERS (#1195 — extracted subsystem) ============
589
650
  // loadItems and setSearch are passed as thunks: both are declared further
590
651
  // down and only ever run post-setup.
652
+ // Where this list remembers what it is showing (#2146).
653
+ //
654
+ // The scope is the entity, so two lists on one route stop colliding — the
655
+ // case page-compositions.md currently tells people to dodge by turning
656
+ // persistence off entirely.
657
+ //
658
+ // `syncUrlParams` governs WRITING, and only ever has: a list configured
659
+ // with `false` still restored from a query string typed by hand. Kept
660
+ // exactly, because moving this behind a seam must not change what any
661
+ // existing app does — the asymmetry is arguable, but arguing it belongs in
662
+ // its own change with its own changelog line.
663
+ //
664
+ // Naming a persister is a deliberate choice about a medium, so it answers
665
+ // the URL flag rather than obeying it: `routeState` set means read AND
666
+ // write, whatever `syncUrlParams` says about the query string.
667
+
591
668
  const listFilters = useListFilters({
592
669
  entityName,
670
+ persister,
671
+ routeStateScope,
672
+ routeStateWrites,
673
+ pageSize,
674
+ sortField,
675
+ sortOrder,
676
+ persistSort,
593
677
  manager,
594
678
  orchestrator,
595
679
  items: items as Ref<unknown[]>,
596
680
  page,
597
681
  searchQuery,
598
- route,
599
- router,
600
682
  savedFilters,
601
683
  persistFilters,
602
- syncUrlParams,
603
684
  autoLoadFilters,
604
685
  filterSessionKey,
605
686
  entityFilters,
@@ -798,7 +879,9 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
798
879
  function onPage(event: { page: number; rows: number }): void {
799
880
  page.value = event.page + 1
800
881
  pageSize.value = event.rows
801
- persistPageSize(event.rows)
882
+ // Written by writeStateToUrl() below, through the persister, rather than
883
+ // straight to a cookie from here (#2146).
884
+ retireLegacyPageSizeCookie()
802
885
  // The page joins the URL (#2113) — it was the only piece of list state
803
886
  // that survived nothing, so leaving a list for a detail view and coming
804
887
  // back dropped the user on page 1.
@@ -818,11 +901,12 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
818
901
  sortField.value = event.sortField
819
902
  sortOrder.value = event.sortOrder as 1 | -1
820
903
  }
904
+ // Written through the persister by writeStateToUrl(), not straight to
905
+ // the session from here (#2146). The legacy entry is retired at the same
906
+ // time, so it cannot shadow the seam on a later read.
821
907
  if (persistSort) {
822
- setSessionSort(filterSessionKey, {
823
- field: sortField.value,
824
- order: sortOrder.value as 1 | -1,
825
- })
908
+ writeStateToUrl()
909
+ clearSessionSort(filterSessionKey)
826
910
  }
827
911
  loadItems()
828
912
  }
@@ -1201,6 +1285,10 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1201
1285
  lazy: true,
1202
1286
  totalRecords: totalRecords.value,
1203
1287
  rows: pageSize.value,
1288
+ // Where the paginator sits (#2145). Derived from `page` rather than held
1289
+ // separately, so the highlighted page and the rows on screen cannot
1290
+ // disagree — which is exactly what they did while this was unbound.
1291
+ first: (page.value - 1) * pageSize.value,
1204
1292
  rowsPerPageOptions,
1205
1293
  sortField: sortField.value,
1206
1294
  sortOrder: sortOrder.value,