Minor Changes
-
c76b488: Route state: five media to choose from, and three levels to choose at
The seam had one persister. It now has the set, and somewhere to declare it
other than on every list.new Kernel({ routeState: 'url' }) // the app-wide floor ctx.entity('audit_rows', { routeState: 'local_storage' }) // every screen showing it useListPage({ entity: 'offers', routeState: 'none' }) // this screen only
Most specific wins: the list → the entity → the kernel → the URL. Each
level exists because somebody has to be able to say it there — a screen's
medium is a screen's decision, an entity's is a modelling one, and an app-wide
floor belongs in the bootstrap.Slug Medium Reach for it when urlquery string the state is worth sending someone — the default local_storagelocalStorageworth keeping, not worth linking session_storagesessionStorageworth keeping until the tab closes cookieone cookie per scope the server needs to read it memorya shared map survives navigation, not a reload nonenowhere this screen forgets, and says so Each medium keeps the same contract and differs only where the medium really
does:- The cookie stores one blob per scope, not one entry per key, because
every cookie rides on every request to your origin — eight filters must not
become eight cookies on every image the page loads. It is also the only one
worth reaching for when the server reads the state; otherwise it is wire
cost for nothing. - Memory outlives the persister that wrote it. A list rebuilds its
persister on mount, so state held on the instance would die on exactly the
navigation this is meant to survive. - Web storage that throws does not take the screen down. Private browsing,
blocked site data and a full quota all raise rather than return null; qdadm
warns once, naming the medium, and the list keeps working. Announced, so not
the silent no-op ADR 0011 forbids. noneis reached only by name. Nothing falls back to it and an unknown
slug still throws — it does nothing because somebody wrote it down.
Values keep their type outside the URL. A query string coerces, because
people read it:?level=42returns the number 42. The other media store JSON,
so a filter holding the string'42'stays a string there. It matters to
code comparing with===.Key naming is overridable per medium, since the right scheme differs by
medium: dots in a query string, colons in web storage, and underscores in a
cookie — where a name is an RFC 6265 token that may contain neither.New doc: route-state.md.
- The cookie stores one blob per scope, not one entry per key, because
-
911f150: The default is composed: the page in the link, the row count out of it
Lot C. The seam's signature was chosen so composition would be expressible
without changing it, and qdadm's own default turned out to be the case that
needed it.Filters, search and the page belong in a link — that is the whole argument for
the URL being the default. Rows-per-page does not. It is a comfort setting
somebody picked once, and a link carrying it would impose the sender's row
count on whoever opens it. So the default routes by key:new CompositePersister({ fallback: new UrlPersister({ router, route }), keys: { pageSize: { persister: new CookiePersister(), scope: 'app' } }, })
Which is what qdadm was already doing, by accident of where the code happened
to live. It is now a decision you can read, override, or copy.Your saved row count survives. It moves from a bare
qdadm_pageSize
cookie to aqdadm_appblob; the old cookie is read when the new store has
nothing, and retired on the first change. Verified in a browser both ways —
upgrade with only the legacy cookie present, and a reload after a change.⚠️
routeState: 'url'is not "the default, stated out loud". It is a pure
URL persister, so the row count joins the query string. Say'default'if you
meant the default.Composing your own works the same way — route any key to any persister, and
pin it to a fixedscopewhen it is one setting across the app rather than one
per screen. Two behaviours worth knowing:- A routed key wins over the fallback. A stale
?pageSize=50somebody
left in a URL does not beat the cookie that owns the setting. - Clearing a list leaves pinned keys alone. "Clear this list's filters" is
not a request to reset an app-wide row count — otherwise one list's clear
button would change every other list's.
An invalid stored size falls back to the default rather than being honoured:
the value arrives from a cookie or query string anyone can edit, and a row
count the paginator cannot offer would leave its dropdown blank. - A routed key wins over the fallback. A stale
-
36c7bec: A seam for route-state persistence:
RouteStatePersisterWhere a route remembers what it is showing is now one interface with one rule,
instead of five unrelated sites that never met: filters insessionStorage
AND the URL, sort insessionStorageonly, page size in a year-long global
cookie, the current page nowhere at all until recently. Each was invented
where it happened to be needed.The list is one consumer of routing state, not the subject — a detail
page's active tab and a dashboard's date range are the same problem, and would
each invent their own answer without this.import { UrlPersister, resolveRouteStatePersister } from '@quazardous/qdadm'
A handler is a slug or an instance, the same convention
storagealready
follows, so consumers meet one shape rather than two. An unknown slug
throws rather than quietly falling back to the URL — a persistence choice
that silently does something else is the failure
ADR 0011 forbids, and the one this seam
exists to clean up after.UrlPersisteris the default medium, because it is the only one where "what I
am looking at" is also "what I can send you". It goes through the router the
app already has, withreplace, and touches no route declaration. Keys are
namespaced by scope (offers.page=2), which is what will let two lists share
a route — the collisionpage-compositions.mdcurrently tells people to avoid
by switching persistence off entirely.Nothing consumes it yet.
useListPagekeeps its current behaviour
unchanged; wiring it up is the next step, deliberately separate so that the
seam can be judged before anything moves onto it.Chrome preferences stay out by design: a collapsed sidebar belongs to the
person, not the route — sending someone a link must not fold their menu. -
4a96272:
useListPageremembers its state through the seam — and its URL keys are now scopedThe list stops writing the query string by hand and goes through
RouteStatePersister. The default is still the URL and stillreplace, so
paging is still not a history step of its own.What you will see change: the keys carry the entity. A list of offers
writes?offers.page=2&offers.search=nginx, not?page=2&search=nginx. That
is the point — it is what lets two lists live on one route without fighting
over a singlepageparameter, the collisionpage-compositions.mdused to
tell people to dodge by turning persistence off.Links written before this keep working. An unprefixed
?page=2is still
read when the scope has nothing of its own, so bookmarks and links pasted into
tickets land where their sender was. The first write retires the flat key
rather than leaving two — and so does clearing the filters, which previously
would have left the address claiming a page the list was no longer showing.How to tell whether this reaches your code at all, in one sweep — a
consumer who never reads the address bar for list state is entirely unaffected,
and most are:route.query / $route.query any read of `page`, `search` or a filter name? location.search / location.href same searchParams same — ignore the ones BUILDING an API requestHits on a list key mean an assertion or a feature of yours expects the flat
shape and will now readnull; no hits mean nothing to do. Worth two minutes
before upgrading rather than finding out from a test that looks like a
regression — a check forpagereturningnullis indistinguishable from the
fix having failed. Read both forms if you need to straddle the versions:
q.get('offers.page') ?? q.get('page').(That sweep is BookShepherd's, from qdadm#2154, generalised here with thanks.)
Choose the medium per list:
useListPage({ entity: 'offers', routeState: 'url' }) // slug… useListPage({ entity: 'offers', routeState: myPersister }) // …or instance
syncUrlParamskeeps the meaning it always had, which is narrower than its
name suggests: it governs writing. It has never stopped a query string
being read, and a hand-typed deep link still restores the list. Setting
routeStateis a deliberate choice of medium, so it answers that flag rather
than obeying it.A filter named
pageorsearchstill warns. The scope separates this list
from other lists, not from qdadm's own keys, so within one list the
collision is real and the warning still names what wins. -
7d45b6c: Route-state keys go through an overridable locator
Hard-coding
offers.pageinsideUrlPersisterwould make the naming scheme a
property of qdadm rather than of the app.RouteStateKeyLocator—encode,
decode,looksScoped— moves that decision out, one per persister type,
because the right scheme differs by medium: dots read well in a query string,
colons are the convention in web storage.new UrlPersister({ router, route, keyLocator: myScheme })
decodereturns null for a key belonging to somebody else. That is what lets
a persister pick its own entries out of a medium it shares with everything
else, and it is the whole reason scopes exist.Old flat links keep working. qdadm wrote
?page=2unprefixed before this
seam, and those links are in bookmarks and pasted into tickets.UrlPersister
reads unprefixed keys when its scope has none of its own, so such a link still
lands on the right page instead of silently showing page 1. Writes are always
prefixed, so a link refreshes itself the first time anyone touches the list,
and the legacy key is retired then rather than left to shadow its replacement.The fallback reads only keys nobody has scoped:
jobs.pagebelongs to the
jobs list, and handing it to another one would be worse than ignoring it. Set
readFlatFallback: falseto drop the compatibility now; it goes away on its
own in a later version.Still consumed by nothing —
useListPageis unchanged. -
ffc1579: The sort joins the seam — and stays exactly where it was
The last of the five persistence sites moves behind
RouteStatePersister.
Nothing you can observe changes, and that is the decision, not an accident.Moving the sort to the URL was the tempting option: a list link would finally
carry its ordering. It would also mean the sort no longer survives opening a
clean/offers, which it always has — a gain nobody asked for against a loss
every existing user would feel. So the default composition routessortback
tosessionStorage, per list, and the seam is worth having without buying it
with that trade.What actually changed is the key:
qdadm_sort_offersbecomes
qdadm:offers:sort. A sort saved before the upgrade is still read when
the seam has nothing, and retired once carried over — the same migration
rows-per-page got.The direction is stored only when there is a field to apply it to. An order on
its own is not partial state, it is meaningless state, and it used to leave a
sortOrdersitting in every unsorted list's namespace.persistSort: falsestill turns it all off.With this the table in
crud.mdfinally describes a design rather than an
accumulation: filters, search and page in the query string; rows-per-page in
an app-wide cookie; sort in the session. One interface, three media, each
chosen for a stated reason.
Patch Changes
-
a62e15c: A search term that looks like a number survives the round trip
Found while a consumer was wiring their search box: their offers list is
searched by reference number, and that turns out to be the case the URL
round trip destroyed.Three parts, each silently dropping the term further along:
The persister coerced too eagerly.
?level=42coming back as the number
42 is right — a query string is meant to be read by people.'007'coming
back as 7 is not, and neither is a 19-digit id coming back as a float. The
coercion now happens only when it round-trips exactly: if the number
cannot be written back as the same text, the text was never a number. Order
ids, invoices, phone numbers and reference prefixes all live in that gap.restoreFiltersrequired a string. A term that did survive the numeric
round trip came back as a number and the guard dropped it, so the box
reopened empty and the list unfiltered — a shared link showed the recipient
something other than what the sender searched for.searchItemsignored anything but a string, returning the whole list
untouched. So even a term that reached a storage as a number filtered nothing,
without a word. Numbers now search; objects and booleans are still ignored,
because there is no sensible text for them.⚠️ Behaviour change worth knowing: a query parameter whose text cannot be
reproduced from its number now stays a string where it used to become a
lossy number.?ref=007gives'007', not7. If you were relying on that
coercion, you were relying on losing information — but check any===
comparison against a number. -
54d49ae: The search box actually searches
Reported against the demo's todos list; it was every list with a search box
over three of the five built-in storages.searchhas been part ofListParamssince the beginning. Three storages
never read it.ApiStoragedropped it before building the request,
MemoryStorageandSdkStoragedropped it before filtering. A user typed, the
list rebuilt its query, the term vanished, the rows came back unfiltered — and
nothing anywhere said why.LocalStorageandMockApiStoragehad always
worked, which is what kept it hidden.The helper they all needed,
searchItems, had existed since #1192. Two
storages called it. This was not a missing capability; it was a missing call,
in the places nobody checked.ApiStoragenow sends the term, under whatever name your backend uses:new ApiStorage({ endpoint: '/posts', paramMapping: { search: 'q' } })
⚠️ Map it, or your backend gets a
searchparameter it has never heard
of — and an API that ignores an unknown parameter answers with everything,
so the list looks exactly as broken as before. This is the one case where the
fix can still leave you with the same symptom, so it is worth checking.searchFieldsdeliberately stays on the front: it says which fields qdadm
should look at when filtering a cached page locally, and a backend that
searches knows its own columns. Shipping an array parameter nobody asked for
would be a new surprise in place of the old one.SdkStorageboth sends the term and applies it locally when
clientSidePaginationis on — otherwise an SDK that ignored it would hand
back the whole collection and that branch would paginate it as though it had
been searched. -
40a7181: A filter type qdadm cannot render now says so — at compile time and at runtime
Reported by a consumer who declared
type: 'text'on two filters.ListPage
is binary — an autocomplete, or aSelectfor everything else, unknown types
included — so both rendered as dropdowns with no options, read "No available
options", and users concluded there was nothing to choose. They had never
filtered anything since the day they were written.Two holes, and the second is larger than the report:
Nothing warned.
addFilternow does, naming the filter, the type, and
what will happen instead — because three screens later there is only an
empty control and no way back to the declaration that caused it.Nothing failed to compile either, and not just for
type.addFilter
tookOmit<FilterConfig, 'name'>;FilterConfigcarries an index signature,
sokeyofincludesstring,Exclude<string, 'name'>is stillstring, and
theOmitcollapsed to a bare{ [x: string]: unknown }— erasing every
declared property. No filter option was type-checked at all: nottype, not
optionLabel, not a misspelledlocal_filter.Fixed by splitting the interface —
FilterOptionsdeclares the properties,
FilterConfig extends FilterOptionsaddsname— so nothing needsOmitand
the index signature stops eating the declarations. Apps can still stash their
own keys.FILTER_TYPESis exported as a value, so a guard on your side can read the
list rather than copy it; a copy goes stale the day a type is added and starts
rejecting correct code.Also new:
tsconfig.typetest.json, because the type-level test that asserts
all this sat undertests/and the type-check config includedsrc/**only —
so its@ts-expect-errordirectives were never read and it would have passed
with the bug back in place. It is now in front of the checker and verified to
go red when the collapse returns. -
06565d3: The paginator shows the page the list is actually on
#2113 put the page number in the URL and it worked — coming back from a
detail view re-requested page 2, and page 2's rows appeared. The paginator
still highlighted 1. The screen said "page 1" over page 2's rows, and the
next click paged from the wrong place.In lazy mode the table renders exactly the rows it is handed and cannot infer
which page they are;first— the row offset — is the only thing that tells
it, and qdadm never passed one. Clicking through worked because the click
moves the table's own internal offset. Nothing else could move it, so every
restored page landed under a paginator stuck on 1.ListPagenow takes afirstprop anduseListPagesupplies it. If you
render your own table fromlist.props, bind:first— a lazy table
without it cannot position its paginator.The tests drive a real PrimeVue paginator and assert which page button is
marked current, rather than checkingfirst === (page - 1) * pageSize. That
arithmetic is the thing under test; asserting it against itself is how the
original defect got shipped — the rows were verified and the paginator was
never looked at.