Skip to content

Name the search query in the links empty state - #55

Merged
DennisAlund merged 4 commits into
mainfrom
claude/issue-52-fix-search-empty-state
Aug 27, 2026
Merged

Name the search query in the links empty state#55
DennisAlund merged 4 commits into
mainfrom
claude/issue-52-fix-search-empty-state

Conversation

@DennisAlund

Copy link
Copy Markdown
Member

Closes #52.

What was already fixed

The report says a search with no matches renders No links yet. Use the + New Link button above to get started. on a catalog holding 200 links. That symptom is gone on main: 4e5c81f (in #50) gave the service a three-value emptyReason, so an empty search result resolves to no-matches instead of falling through to the first-run copy.

What was left

no-matches carries two different claims. Either a filter chip selected none of the catalog, or a search matched none of it. Both rendered the same string:

No links match the current filter.

Searching zzz blamed a filter the user never touched, and never named the query that came back empty. That is the fix the issue asks for: "a third empty-state branch keyed on searchQuery, with copy naming the query that matched nothing."

The change

The service hands back one reason and the page owns the query, so the split lands in the page. emptyStateCopy (src/pages/links.tsx:124) takes the reason and the query and picks links.noSearchMatches whenever a search is what emptied the window. Lifting the choice out of the JSX also keeps a fourth arm from growing the nested ternary another level.

New key in all three locales, per the CLAUDE.md i18n rule:

Locale links.noSearchMatches
en No links match "{query}".
id Tidak ada tautan yang cocok dengan "{query}".
sv Inga länkar matchar "{query}".

Translations is typed off en, so id and sv cannot lag behind the key.

The query reaches the copy as a t() parameter and hono/jsx escapes string children, so markup typed into the search box renders as text. A test pins that rather than trusting it.

Tests

Three added to src/__tests__/page/links-page.test.ts, all watched failing first:

  • names the query in the empty state when a search matched nothing asserts the query appears and neither current filter nor No links yet does.
  • keeps the filter wording when a filter, not a search, emptied the list guards the filter case against the new branch swallowing it.
  • escapes markup in a search query before naming it in the empty state asserts <b>zzz</b> and no raw tag.

No existing test was modified or removed.

$ yarn tsc --noEmit
Done in 1.96s.

$ yarn test --run
Test Files  83 passed (83)
     Tests  1245 passed (1245)

No API surface change, so all three SDK spec hashes stay put: ./scripts/spec-hash.sh still returns d4cf1923…deb956, matching what the manifests record.

🤖 Generated with Claude Code

Issue #52 reported a search with no matches rendering "No links yet. Use the
+ New Link button above to get started." on a catalog holding 200 links. That
half is already gone: 4e5c81f gave the service a three-value emptyReason, so a
search that matches nothing now resolves to no-matches rather than falling
through to the first-run copy.

What survived is the other half of the report. no-matches carries two different
claims. A filter chip selected none of the catalog, or a search matched none of
it, and both rendered "No links match the current filter." Searching zzz blamed
a filter the user never touched and never said which query came back empty.

The service cannot tell the two apart in copy, since it hands back one reason
and the page owns the query. So the split lands in the page: emptyStateCopy
takes the reason and the query, and picks links.noSearchMatches with the query
interpolated whenever a search is what emptied the window. Pulling the choice
out of the JSX also stops a fourth arm from growing the ternary another level.

links.noSearchMatches goes into en.ts, id.ts and sv.ts. Translations is typed
off en, so the other two locales cannot lag behind it.

The query reaches the copy as a t() parameter and hono/jsx escapes string
children, so markup in the search box renders as text. A test pins that rather
than trusting it.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 26, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
shrtnr 0ae8233 Aug 27 2026, 12:17 AM

Review of #55 turned up an empty-state case the first commit left standing.

A search of nothing but spaces reaches the repository, which treats a query
that trims to empty as matching nothing rather than letting a bare LIKE "%%"
match every row. The window comes back empty while the catalog is full, and the
reason is then picked from the filter alone: under the default Active chip that
renders "All links are disabled. Pick the Disabled filter to see them." over a
catalog where nothing is disabled, and under filter=all it renders the
blame-the-filter wording this branch set out to remove. The route now trims the
query before anything reads it, so a run of spaces is no search at all and the
listing renders its rows.

Trimming at the route also collapses the two places that tested search.trim():
the service still decides all-disabled vs no-matches, but it can no longer see
a query the page would call empty.

emptyStateCopy grows an exhaustive switch. The old negative test
(emptyReason !== "no-matches") mapped anything unrecognised onto the first-run
copy, so a fourth reason would have shipped "No links yet" over a full catalog
with no build error. A never-typed default fails the build instead; adding a
member to the union was checked to confirm it does.

The query is user input pasted into a centred one-paragraph block, so it clips
at 60 characters. Escaping already made it safe, this keeps it readable.

emptyStateCopy is exported and unit tested alongside pageWindow and
paginationItems, which is where the whitespace and long-query cases are cheap
to cover. The page tests keep the rendered-HTML cases and now assert the whole
sentence rather than the query substring, and they use the seed() helper the
enclosing describe already provides.

Placeholder parity gets a guard. Translations is typed off en, so a missing key
fails the build, but nothing typed the {query} inside a value: a locale could
keep the key and drop the interpolation the key exists for. Dropping it from
id.ts was checked to confirm the new test fails.
@DennisAlund

Copy link
Copy Markdown
Member Author

Self-review pass over this branch. Ten findings; seven applied in 016a36a, two need a call from you, one I disagree with.

Applied

Whitespace-only search rendered a false claim. ?search=%20 on a full catalog: the repository treats a query that trims to empty as matching nothing, rather than letting a bare LIKE "%%" match every row, so the window comes back empty and the reason gets picked from the filter alone. Under the default Active chip that read All links are disabled. Pick the "Disabled" filter to see them. over a catalog where nothing is disabled. Under ?filter=all it read No links match the current filter. with the filter set to All. Both are pre-existing on main, and both are exactly the class of bug #52 is about, so they belong here. The route trims the query before anything reads it, so a run of spaces is no search and the listing just renders.

Non-exhaustive reason test. emptyReason !== "no-matches" mapped anything unrecognised onto the first-run copy, so a fourth LinksEmptyReason would have shipped No links yet over a full catalog with no build error. Now an exhaustive switch with a never-typed default. Confirmed rather than assumed: adding a "probe" member to the union gives src/pages/links.tsx(162,13): error TS2322: Type '"probe"' is not assignable to type 'never'.

No placeholder parity guard. The PR body claimed Translations being typed off en keeps the locales honest. That is true for key presence and false for the {query} inside a value: a locale could keep the key and drop the interpolation it exists for. New test in i18n.test.ts. Confirmed it bites: dropping {query} from id.ts fails it. Every existing key already passes, so it locks in current state.

Unbounded query length. The query now clips at 60 characters with an ellipsis. Escaping already made it safe; this stops a pasted essay from filling the viewport and pushing the toolbar and paginator out of view.

Helper not exported. emptyStateCopy now sits next to pageWindow and paginationItems as an exported pure function, with the branch matrix tabled in src/__tests__/unit/links-empty-state.test.ts. The whitespace and long-query cases cost a function call there instead of a DB seed plus a SELF.fetch.

Weak headline assertion. toContain("zzz") passed on a bare query or an unreplaced placeholder sitting next to one. Now asserts No links match "zzz".

Duplicated seeding. The three page tests use the seed(3) helper the enclosing describe already provides.

Needs your call

1. Search plus a narrowing filter blames only the query. ?filter=disabled&search=one with three active links slugged one/two/three renders No links match "one". A link matching one does exist, one chip away. This is a genuine regression from the first commit: main renders No links match the current filter. there, which is the more accurate half of the pair. There is no short sentence that is honest in both directions, so it needs a copy decision, and every option collides with an assertion already on this branch. Options: name the filter too (No links match "one" in Disabled.), always say under the current filter, or accept the current wording as scoped to the visible view. I did not want to pick UX copy for three locales unilaterally.

2. The split arguably belongs in the service. listLinksPage already computes search.trim() to choose all-disabled vs no-matches, and the page recomputes it. A fourth reason no-search-matches emitted where the trim already lives would give one source of truth and keep the page a pure reason-to-string map. I did not do it because it breaks reports no-matches when a search finds nothing in a populated catalog (link-page-service.test.ts:88), and CLAUDE.md says to stop and notify rather than modify an existing test. Route-level trimming removes the divergence in practice, so this is now structure rather than a live bug. Say the word and I will do it as a follow-up with the test change made deliberately.

Disagree

Fold the filter-regression test into the existing one. Adding the current filter assertion to does not claim every link is disabled when the disabled filter matched nothing would edit a committed test, which CLAUDE.md forbids outright. A separate test is the compliant shape. It does now share the seed(3) helper, so the duplication is four lines rather than ten.

Verification

$ yarn tsc --noEmit
Done in 2.13s.

$ yarn test --run
Test Files  84 passed (84)
     Tests  1255 passed (1255)

The uncaught exception; internal error lines in that output are workers-pool noise, not this branch: five occurrences on main and five here.

Two decisions from the review of #55.

The service now owns the split. LinksEmptyReason gains no-search-matches, and
listLinksPage raises it wherever the trimmed query is what emptied the window.
The page had been re-deriving "did a search happen" from the raw query, one
layer above the trim that already answered it, so the two could disagree. The
page is now handed the answer.

A live search outranks the status filter when picking the reason. The query is
the specific thing the copy can name back, and it is the more likely culprit.
Everything else is unchanged: an empty catalog is still no-links, an active
filter over an expired catalog still all-disabled, and a status filter that
selected nothing still no-matches.

This renames the assertion in "reports no-matches when a search finds nothing
in a populated catalog", which is an existing test. CLAUDE.md forbids editing
one to fit a code change, so the conflict went to Dennis first and the rename
is deliberate: the value it named no longer describes that case.

The copy names both narrowing dimensions. Searching one under the Disabled chip
rendered `No links match "one".` while a link matching one sat a chip away, so
the branch had swapped blaming the filter for blaming the query. Under active
or disabled the copy now names the chip too, and under all, where nothing but
the search is hiding rows, it stays as it was.

The chip table moves to module scope. The empty state and the chips read their
labels from one place, so the filter a user clicks and the filter the copy names
cannot drift.
@DennisAlund

Copy link
Copy Markdown
Member Author

Both open decisions are settled and pushed in c8fbe92.

Copy names both narrowing dimensions

Request Empty state
?search=zzz (Active is the default chip) No links match "zzz" in Active.
?filter=disabled&search=one No links match "one" in Disabled.
?filter=all&search=zzz No links match "zzz".
?filter=disabled (no search) No links match the current filter.

Under all nothing but the search is hiding rows, so there is no filter worth naming. Under active or disabled both are narrowing, and blaming either alone is half the truth: the case that prompted this was ?filter=disabled&search=one rendering No links match "one". with a matching link sitting one chip away.

links.noSearchMatchesInFilter added to all three locales. The chip table moved to module scope as FILTER_CHIPS, so the chip a user clicks and the filter the copy names read from one place and cannot drift.

Split moved into the service

LinksEmptyReason gains no-search-matches, raised by listLinksPage wherever the trimmed query is what emptied the window. The page no longer re-derives "did a search happen" from the raw query one layer above the trim that already answered it. A live search outranks the status filter when picking the reason, since the query is the specific thing the copy can name back. Every other reason is unchanged.

This renamed the assertion in reports no-matches when a search finds nothing in a populated catalog (link-page-service.test.ts:88). CLAUDE.md forbids editing an existing test to fit a code change, so that conflict went to Dennis before anything was touched, and the rename is deliberate: the value it named no longer describes that case. A second service test covers a search and a status filter narrowing together.

Verification

$ yarn tsc --noEmit
Done in 2.10s.

$ yarn test --run
Test Files  84 passed (84)
     Tests  1259 passed (1259)

The exhaustiveness guard was re-checked after the union grew: adding a "probe" member still gives src/pages/links.tsx(180,13): error TS2322: Type '"probe"' is not assignable to type 'never'. The placeholder parity test covers the new two-placeholder key across id and sv.

./scripts/spec-hash.sh still returns d4cf1923…deb956, so no SDK hash bumps: LinksEmptyReason is admin-page plumbing and never reaches the OpenAPI surface.

…el arm

Two findings from a fresh read of this branch.

The clip cut UTF-16 units. A query long enough to trim, with its emoji sitting
on odd offsets, lost half a surrogate pair at the boundary and the response
carried an unpaired surrogate that a browser draws as U+FFFD. Spreading the
string first counts and cuts code points instead.

The first attempt at the test picked an all-emoji query, where every pair
straddles the 60th unit evenly and a naive slice gets away with it, so the test
passed against the bug. A leading letter shifts every pair onto an odd offset
and the cut lands inside one. The test was checked against the old slice to
confirm it fails there.

Naming the filter went through FILTER_CHIPS.find(), which returns undefined for
a key that is not in the list. LinksFilter has three members and the list covers
all three, so the not-found arm could never run and existed only to satisfy the
type. A Record keyed by LinksFilter is total: no fallback, and a filter added to
the union fails the build here rather than falling back to its raw key. The
chips now read their labels from the same table.
@DennisAlund

Copy link
Copy Markdown
Member Author

Fresh read of the whole branch, treating it as someone else's work. Two findings, both fixed in 0ae8233. Two more checked and dismissed.

Fixed

The clip cut UTF-16 units, not characters. A query long enough to trim, with its emoji on odd offsets, lost half a surrogate pair at the boundary and shipped an unpaired surrogate that renders as U+FFFD. [...query] counts and cuts code points instead.

Worth recording how this nearly slipped through: the first version of the test used an all-emoji query, where every pair straddles the 60th unit evenly and a naive slice gets away with it. It passed against the bug. A leading letter shifts every pair onto an odd offset and the cut lands inside one:

"👍".repeat(40).slice(0,60)        -> lone surrogate: false
("a"+"👍".repeat(40)).slice(0,60)  -> lone surrogate: true

The corrected test was run against the old slice to confirm it fails there before the fix went in.

The filter label had an unreachable arm. FILTER_CHIPS.find((chip) => chip.key === filter)?.labelKey returns undefined for a key not in the list, forcing label ? t(label) : filter. LinksFilter has three members and the list covers all three, so that fallback could never run and existed only to satisfy the type. FILTER_LABEL is a Record<LinksFilter, TranslationKey>, total by construction: no fallback, and a filter added to the union now fails the build here instead of silently rendering its raw key. The chips read their labels from the same table.

Checked and dismissed

Adding a member to LinksEmptyReason could strand a consumer. Grepped: listLinksPage and LinksPage have exactly one caller each, both in src/index.tsx. No widget island reads the reason yet, so nothing can receive no-search-matches without handling it.

A search over a fully expired catalog now reports the search rather than all-disabled. Real, and deliberate. ?search=one with every link expired renders No links match "one" in Active., which is true: Active holds nothing. main rendered No links match the current filter. there, so this is not a regression, and the query is the more specific thing to name. Left as is.

Verification

$ yarn tsc --noEmit
Done in 2.09s.

$ yarn test --run
Test Files  84 passed (84)
     Tests  1260 passed (1260)

@DennisAlund
DennisAlund merged commit 4f9a224 into main Aug 27, 2026
9 checks passed
@DennisAlund
DennisAlund deleted the claude/issue-52-fix-search-empty-state branch August 27, 2026 00:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A links search with no matches says "No links yet"

1 participant