Two dashboard gotchas — TanStack string search params JSON-quote in the URL, and qa scenarios that race an async feed's loading-time empty state
TanStack string search params JSON-quote; async-feed qa scenarios race the empty state
Two independent dashboard-wave bugs (SPEC-624 / SPEC-621-622-625), both caught only by the per-merge live qa replay.
1. String search params serialize with quotes
TanStack Router’s default search serializer JSON-encodes each value. A string
"1" becomes ?version=%221%22 (a quoted "1"); a number 1 becomes the
clean ?version=1. The Trust version-history link passed the version as a string,
so /world-model-card?version=%221%22 never matched the World Model Card route’s
numeric pin, and the deep link silently failed.
Fix: type the param as a number end-to-end — the route’s validateSearch
returns { version?: number } (coercing Number(value) when finite), the
consumer prop is number, and the producing <Link search={{ version }}>
passes a number. Don’t pass a string and hope the URL comes out bare.
// routevalidateSearch: (s: Record<string, unknown>): { readonly version?: number } => { const n = typeof s.version === "number" ? s.version : typeof s.version === "string" ? Number(s.version) : Number.NaN; return Number.isFinite(n) ? { version: n } : {};},// link — version is a number, so the URL is ?version=1 (not ?version=%221%22)<Link to="/world-model-card" search={{ version }}>…</Link>2. Playwright scenarios race an async feed’s loading-time empty state
A React-Query-backed list renders its empty state while the fetch is in
flight (data is undefined → length === 0 → empty). A qa helper that
checks await link.isVisible() immediately after goto() — or even
expect(link.or(emptyState).first()).toBeVisible() — resolves against that
transient empty state and wrongly concludes “no data”, skipping a scenario that
should run (the customer decision deep-dive skipped on a feed of 18 real rows).
Fix: poll for the populated signal directly with a timeout, and only treat its absence as empty:
const appeared = await detailLink .waitFor({ state: "visible", timeout: 10_000 }) .then(() => true).catch(() => false);if (!appeared) { test.skip(true, "genuinely empty"); return false; }waitFor({state:"visible"}) keeps polling until the row appears once the data
loads; it does not short-circuit on the loading-time empty state. Reserve the
.or(emptyState) race for surfaces with no async load.
Prevention
- For any typed deep link, assert the rendered
hrefis the bare expected form in a qa scenario (/route?param=1), not just that navigation “works”. - For any async list, the qa precondition that decides skip-vs-run must wait for the populated element, never sample the immediate post-nav DOM.
References
b711d847/ the SPEC-624 version-link fix (number-typed?version=N)- The
openFirstDecisionhelper fix inqa/customer/tests/dashboard-reads.spec.ts