Skip to content

fix: replace abort-signal staleness check with generation counter in search (#144)#151

Merged
zacharias-ona merged 1 commit intomainfrom
fix/144-search-empty-state-generation-counter
Apr 17, 2026
Merged

fix: replace abort-signal staleness check with generation counter in search (#144)#151
zacharias-ona merged 1 commit intomainfrom
fix/144-search-empty-state-generation-counter

Conversation

@zacharias-ona
Copy link
Copy Markdown
Collaborator

Closes #144

What

The search empty state ("No pages match your search") never renders when searching for a nonsense string. Instead, skeleton loading placeholders show indefinitely. This bug persisted across three previous fix attempts (#126, #136, #146) because the root cause — a microtask ordering race between the AbortSignal and React state updates — was never addressed.

How

Replaced the signal.aborted guard in the search callback's finally block with a generation counter ref (searchGenRef). Each search cycle increments the counter synchronously in the debounce effect. Async callbacks only update state when their captured generation matches the current ref value.

The previous approach checked signal.aborted in the finally block to decide whether to clear loading. This failed because: when the debounce effect re-runs, it aborts the old controller and sets loading=true synchronously, but the aborted fetch's finally block runs later as a microtask. If the abort races with fetch completion (signal not yet aborted when finally runs), the finally block could set loading=false, undoing the new cycle's loading=true. The generation counter is immune to this because it is incremented synchronously before any microtask from the previous cycle can run.

The AbortController is retained for network efficiency (cancelling in-flight requests), but state management decisions are now driven entirely by the generation counter.

Testing

  • Added regression test "shows empty state even when aborted fetch finally block races with new cycle" that exercises the exact race condition with an immediately-resolving fetch
  • All 174 unit tests pass (pnpm test)
  • All 42 E2E tests pass (pnpm test:e2e), including the previously failing search with no matches shows empty state
  • pnpm lint && pnpm typecheck pass

…search (#144)

The search empty state ("No pages match your search") never rendered
because the loading state could get stuck at true. The previous fix
used AbortSignal.aborted in the finally block to decide whether to
clear loading. This created a microtask ordering bug: when the
debounce effect re-runs, it aborts the old controller and sets
loading=true synchronously, but the aborted fetch finally block
runs later as a microtask. If the abort races with fetch completion,
the finally block could either skip clearing loading (leaving it
stuck) or clear it at the wrong time.

Replace the signal.aborted guard with a generation counter ref.
Each search cycle increments the counter synchronously. Async
callbacks only update state when their generation matches the
current one. This is immune to microtask ordering because the
counter is incremented in the same synchronous block that sets
loading=true.

Co-authored-by: Ona <no-reply@ona.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
memo Ready Ready Preview, Comment Apr 17, 2026 0:53am

Request Review

@zacharias-ona zacharias-ona merged commit 764c672 into main Apr 17, 2026
6 checks passed
@zacharias-ona zacharias-ona deleted the fix/144-search-empty-state-generation-counter branch April 17, 2026 00:57
@zacharias-ona
Copy link
Copy Markdown
Collaborator Author

✅ UI verification passed — design spec compliance confirmed.

Static analysis: No visual changes in this PR. The diff replaces signal.aborted staleness checks with a generation counter (searchGenRef) in page-search.tsx. No JSX, CSS classes, color tokens, typography, spacing, or component usage was modified.

Visual verification (Playwright screenshots):

  • Desktop dark: sidebar, page tree, workspace layout all render correctly
  • Mobile: sidebar collapsed, hamburger visible, content fills width, no horizontal scroll
  • Search active: input focus ring present, skeleton loaders use bg-muted animate-pulse with sharp corners per spec

All design spec checks pass.

@zacharias-ona
Copy link
Copy Markdown
Collaborator Author

✅ Post-merge verification passed.

E2E suite: 41/42 passed. The 1 failure (typing in search input shows matching results) is a known flaky test (see #118) — the screenshot confirms search results render correctly; the [role="option"] locator times out intermittently. Not a regression from this PR.

Ad-hoc smoke tests: All passed.

Route Result
/ (landing page) ✅ Loaded, has title
/sign-in ✅ Email input present
/api/health ✅ Healthy
Auth flow (sign-in → workspace) ✅ Login redirects to workspace
Workspace page ✅ Fully loaded
Editor (page navigation) ✅ Editor element rendered

Notably: search with no matches shows empty state — the test this PR specifically fixed — passes reliably.

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.

bug: search empty state still broken — "No pages match your search" never renders

1 participant