The router matches active state against location.pathname, so any link whose href carries a query string loses its active class and never receives aria-current. The link that is not filtered gets both instead, so assistive tech is told the wrong thing.
Query-string links are how filter chips, tab bars and sort controls are normally built, so this affects a common pattern rather than an exotic one.
Reproduction
Using the repo's own SPA harness, booted at /list?status=up:
layout('<nav>'
+ '<a href="/list?status=up" data-stx-link class="chip active" id="up">Up</a>'
+ '<a href="/list?status=down" data-stx-link class="chip" id="down">Down</a>'
+ '<a href="/list" data-stx-link class="chip" id="all">All</a>'
+ '</nav>')#up class: "chip" aria-current: null
#all class: "chip exact-active active" aria-current: pageThe server rendered class="chip active" on #up, because ?status=up is the filter in effect. After updateActiveLinks() runs:
#uphas lostactiveentirely, so the chip the user is on is styled as inactive#allhas gainedactive,exact-activeandaria-current="page", so the unfiltered link claims to be the current page
Cause
dist/chunk-bqdbf8k2.js:
function updateActiveLinks() {
var links = document.querySelectorAll('[data-stx-link]')
var cur = location.pathname // <- query string dropped
links.forEach(function (link) {
var href = link.getAttribute('to') || link.getAttribute('href') || ''
ac.split(' ').forEach(cls => link.classList.remove(cls)) // removed unconditionally
var isExact = cur === href
var isActive = href !== '/' ? cur.startsWith(href) : cur === '/'
if (isActive) ac.split(' ').forEach(cls => link.classList.add(cls))
markCurrent(link, isExact)
})
}cur has no search component while href does, so cur === href and cur.startsWith(href) are both false for every query-carrying link. Meanwhile a bare /list href passes cur.startsWith('/list'), which is why the unfiltered link wins.
Note the class is removed before anything is re-added, so this also overwrites correct server-rendered state. An app that computes the active filter server-side, which is the natural thing to do when the filter comes from the query, has its markup undone on the client.
Two things that make it hard to spot
markCurrent only clears aria-current when the value is exactly 'page':
if (link.getAttribute('aria-current') === 'page') link.removeAttribute('aria-current')So a consumer who works around the missing attribute by stamping aria-current="true" server-side keeps it forever: the router neither updates nor clears it, and after one SPA navigation it names the wrong link. The workaround is worse than the bug.
It also only shows after client-side navigation. A full load renders correct server markup, so this looks fine until the router takes over.
Suggested
Compare like with like. Use location.pathname + location.search as cur when the href has a search component, or parse both and compare path and query separately. isActive should probably stay prefix-based on the path while isExact considers the query, so a nav link to /list can still be "active" on /list?status=up without being "exact".
Also worth not removing the active class before deciding, so that a link the router has no opinion about keeps whatever the server rendered.
Impact
Found while measuring component adoption across six apps. Query-carrying router links: statushq 22, analyticshq 20, loghq 5, bughq 2. statushq's dashboard filter chips are the clearest case — 28 chips across 10 views, all of the form ?status=, ?type=, ?range=.
Related: upstream's own FilterChip (src/ui/mobile/FilterChip.stx) stamps aria-current="true" server-side on its active chip, so it has the stale-attribute problem described above as soon as the row is used for query-string filters.