A search box over 56 server-rendered cards, 13,486 characters of index, and the word pilot matching none of them
cogniprep.app/games is a hub of 56 provider cards. Until this week it was a static grid: if you had an email telling you to sit something called a VEA 475, you scrolled. It now has a search box, and the interesting part
cogniprep.app/games is a hub of 56 provider
cards. Until this week it was a static grid: if you had an email telling you to
sit something called a VEA 475, you scrolled.
It now has a search box, and the interesting part is not the matching. It is
what had to stay on the server, what had to be shipped to the browser to make
filtering work at all, and the two queries a real candidate would type that
return nothing.
The cards are rendered on the server and the query only reorders them
The hub page is a server component. It maps the provider list into card props
and hands the whole array to a client component:
<ProviderDirectory providers={PROVIDERS} />
So the first paint and the HTML a crawler receives contain every card, search
box or not. You can count them without a browser:
curl -s https://cogniprep.app/games | grep -o 'href="/games/[a-z0-9-]*"' | sort -u | wc -l
# 56
The component's own doc comment says the query "only hides cards", which is the
intent and not quite what the code does:
const visible = useMemo(() => {
const tokens = tokenize(query);
if (tokens.length === 0) return providers;
// Array.prototype.sort is stable, so equal scores keep the canonical order.
return providers
.map((provider) => ({ provider, score: scoreMatch(provider.label, provider.haystack, tokens, rawQuery) }))
.filter((scored) => scored.score >= 0)
.sort((a, b) => b.score - a.score)
.map((scored) => scored.provider);
}, [providers, query]);
That filter removes the non-matching cards from the DOM, it does not hide them.
I checked in the browser: 56 anchors with the box empty, one anchor after typing
474, zero after typing zzzz. The property that matters for search engines is
not that the cards are hidden, it is that they are in the first render, and
nothing a visitor types afterwards can change the document the crawler already
fetched. Those are two different claims and only the second one is true, so the
comment is now wrong and will be fixed.
The index is 56 strings and 13,486 characters of the page
Client-side matching needs the searchable text in the client, so the server
flattens each provider into one lowercased string:
// The shared index covers the label, aliases in the meta description and
// game names; the card's own copy and tags are added so anything visible on
// a card also finds it.
haystack: [
PROVIDER_SEARCH_BY_ID[id].haystack,
PROVIDER_CARD_COPY[id].description,
...PROVIDER_CARD_COPY[id].tags,
]
.join(' ')
.toLowerCase(),
Those strings travel in the flight payload inside the HTML document. Pulling
them out of the live page gives 56 of them, 13,486 characters in total, the
longest 1,020 characters (Thomas International), on a 267 KB page. That is the
price of not having a search endpoint: about 13 KB of duplicated text, paid by
everyone who loads the hub, including the people who never type anything.
I would still pay it. The alternative is a round trip per keystroke for a list
that fits in a fifth of a megabyte, and a second code path to keep in agreement
with the first.
One of the 56 is not a string
Reading the payload turned up something I did not expect. 55 of the haystacks
are inline strings. One of them is this:
{"id":"usps","label":"USPS (Postal Exams)","href":"/games/usps","gameCount":6,
"description":"The US Postal Service hiring exams: all four Virtual Entry Assessment versions, ...",
"tags":["VEA 474","VEA 475","VEA 476","VEA 477","Exam 955"],
"haystack":"$16"}
"$16" is a reference in React's flight format, not a literal. The serializer
decided that string was worth emitting once and pointing at. It resolves, which
is the part I wanted to be sure of before writing this down:
Open cogniprep.app/games, type 474, and exactly
one card survives: USPS. A token that exists
only inside that referenced string still matches, so the reference is being
resolved during hydration like any other value. Worth knowing the next time you
grep a flight payload and conclude a prop is missing.
Two queries a real candidate types, and nothing comes back
The empty state reads:
No provider matches "zzzz". Try the name of the test on your invitation, or
start from the test type below.
Here is the sweep I ran against the live page, counting cards per query:
| Query | Matches |
|---|---|
atsa |
1 |
firefighter |
1 |
mckinsey |
1 |
army |
4 |
police |
6 |
aptitude |
12 |
situational |
13 |
personality |
21 |
numerical |
22 |
pilot |
0 |
nhs |
0 |
The index holds provider names, aliases, game names, card copy and tags. It does
not hold the job you are applying for. atsa finds
the FAA test because that is its name;
pilot finds nothing, even though a pilot is exactly who sits it. nhs finds
nothing for the same reason.
That is a content gap, not a matching bug, and the advice in the empty state is
the tell: it has to tell you to type the name of the test, because the name of
the test is the only thing the index is sure to contain. The fix is to put job
and employer words into the index, not to loosen the matching.
Two cross-matches in the same table look wrong and are not. shl returns three
cards, because two other suites in the list are built on SHL material and say so
in their own copy. pymetrics returns two, because the other provider acquired
it. Aliases are in the index on purpose, and this is what that looks like from
the outside.
Small things the live HTML shows you
-
A branch that never renders. Cards support a
Coming soonchip for providers with no playable games.grep -c 'Coming soon'on the live page is 0, because all 56 currently have games. It is dead code today and I am leaving it, because the next provider lands in that state for a few hours. -
34 overflow chips. A card lists a handful of tags and then
+N more, computed asgameCount - tags.lengthso it cannot disagree with the count printed above it. 34 of the 56 cards show one. -
Counting text through React's separators.
grep -o '[0-9] games' | wc -lfinds nothing on the live page, because the number and the word are separate text nodes and React puts a comment between them. The pattern that works is[0-9]<!-- --> games, which matches 56 times, and+<!-- -->[0-9]*<!-- --> more, which matches 34. -
A 16px input on purpose. The field is
text-baseat every breakpoint, with a comment saying why: anything smaller and iOS Safari zooms the page when the field takes focus. -
The announcement is the only thing a screen reader gets. The result count
lives in an
sr-onlyparagraph witharia-live="polite", empty until you type, then1 provider matchesor0 providers matchwith the plural branch spelled out. Without it, filtering a grid is silent. -
autoCompletesurvives into the markup. The server HTML containsautoComplete="off", camel case and all, and the DOM still reportsgetAttribute('autocomplete') === 'off', because HTML attribute names are case-insensitive at parse time.
See it for yourself: open cogniprep.app/games,
run the curl | grep -c above to confirm all 56 cards are in the HTML before any
JavaScript runs, then type 474, shl and pilot and watch a 13 KB index earn
its keep twice and fail once.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.