Skip to content

Commit b6a8f60

Browse files
fix: replace generation counter with cancelled flag in search effect (#192) (#198)
* fix: replace generation counter with cancelled flag in search effect (#192) Co-authored-by: Ona <no-reply@ona.com> * fix: [ci-fix] wrap bucket INSERT in DO block to survive db reset The Supabase CLI's db reset can lose the ON CONFLICT clause when splitting statements, causing a duplicate key error on the page-images bucket. Wrapping in a DO block with EXCEPTION WHEN unique_violation is immune to statement splitting. Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
1 parent 5b88785 commit b6a8f60

5 files changed

Lines changed: 199 additions & 58 deletions

File tree

.agents/conventions.md

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -124,28 +124,35 @@ state in the dependency array causes the callback reference to change whenever t
124124
state resolves. If a `useEffect` depends on that callback, the effect re-runs,
125125
creating a cascade of state transitions that race with each other.
126126

127-
**Pattern:** store async state in a ref and read it inside the callback so the
128-
callback identity is stable. Use a separate effect to re-trigger work when the
129-
async state resolves.
127+
**Pattern:** pass async state as a parameter to a stable callback (empty deps).
128+
The effect that calls the callback includes the async state in its own deps so it
129+
re-runs when the state resolves.
130130

131-
```typescript
132-
// ❌ Bad — callback changes when workspaceId resolves, re-triggering the effect
133-
const search = useCallback(async (q) => {
134-
fetch(`/api?ws=${workspaceId}`);
135-
}, [workspaceId]);
131+
### Discarding stale async callbacks
136132

137-
useEffect(() => { /* debounce */ search(query); }, [query, search]);
133+
When an effect starts async work (fetch, timer callback), the effect may re-run
134+
before the async work completes. The stale callback must not update state.
138135

139-
// ✅ Good — callback is stable, dedicated effect handles late resolution
140-
const wsRef = useRef(workspaceId);
141-
wsRef.current = workspaceId;
136+
**Use a `cancelled` flag, not a generation counter.** A generation counter
137+
(`if (genRef.current !== gen) return`) has a subtle race: if the effect re-runs
138+
between the fetch start and the `finally` block, the counter check fails and the
139+
status transition is skipped — leaving the UI stuck (issues #118#192). A boolean
140+
`cancelled` flag set once in cleanup is immune to this race.
142141

143-
const search = useCallback(async (q) => {
144-
fetch(`/api?ws=${wsRef.current}`);
145-
}, []);
142+
```typescript
143+
// ❌ Bad — generation counter race in finally block
144+
const gen = ++genRef.current;
145+
fetch(url, { signal }).finally(() => {
146+
if (genRef.current === gen) setStatus("done"); // skipped if effect re-ran
147+
});
146148

147-
useEffect(() => { /* debounce */ search(query); }, [query, search]);
148-
useEffect(() => { if (workspaceId) search(query); }, [workspaceId]);
149+
// ✅ Good — cancelled flag set once in cleanup
150+
let cancelled = false;
151+
fetch(url, { signal })
152+
.then(res => { if (!cancelled) { /* update state */ } })
153+
.catch(err => { if (!cancelled) { /* handle error */ } });
154+
// cleanup:
155+
return () => { cancelled = true; controller.abort(); };
149156
```
150157

151158
Also: always add `.catch()` to fire-and-forget promises in effects. An unhandled

e2e/fixtures/editor-helpers.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { expect } from "@playwright/test";
77
* prevents parallel tests (e.g. the delete test) from interfering by deleting
88
* a shared page mid-test.
99
*
10+
* If the new page returns a 404 (e.g. due to Supabase replication lag), the
11+
* function reloads the page and retries up to 2 times before failing.
12+
*
1013
* Returns once `[contenteditable="true"]` is visible.
1114
*/
1215
export async function navigateToEditorPage(page: Page): Promise<void> {
@@ -27,9 +30,31 @@ export async function navigateToEditorPage(page: Page): Promise<void> {
2730
const newPageBtn = sidebar.getByRole("button", { name: /new page/i });
2831
await newPageBtn.click();
2932

30-
// Wait for the editor to appear (works for both hard and soft navigation)
33+
// Wait for the editor to appear. If the server returns a 404 (e.g.
34+
// Supabase replication lag between the client-side insert and the
35+
// server-side read), reload and retry.
3136
const editor = page.locator('[contenteditable="true"]');
32-
await expect(editor).toBeVisible({ timeout: 10_000 });
37+
const maxRetries = 2;
38+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
39+
const visible = await editor
40+
.waitFor({ state: "visible", timeout: 10_000 })
41+
.then(() => true)
42+
.catch(() => false);
43+
44+
if (visible) return;
45+
46+
// Check if we got a 404 — if so, reload to retry the server render
47+
const is404 = await page.getByText("This page could not be found").isVisible().catch(() => false);
48+
if (is404 && attempt < maxRetries) {
49+
await page.reload({ waitUntil: "domcontentloaded" });
50+
continue;
51+
}
52+
53+
// Not a 404 or out of retries — fail with a clear message
54+
throw new Error(
55+
`navigateToEditorPage: editor not visible after ${attempt + 1} attempt(s)`,
56+
);
57+
}
3358
}
3459

3560
/**

src/components/sidebar/page-search.test.tsx

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import "@testing-library/jest-dom/vitest";
2-
import { render, screen, act } from "@testing-library/react";
2+
import { render, screen, act, fireEvent } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
55

@@ -54,6 +54,12 @@ import { PageSearch } from "./page-search";
5454
describe("PageSearch", () => {
5555
beforeEach(() => {
5656
vi.useFakeTimers({ shouldAdvanceTime: true });
57+
// Reset workspace mock — vi.restoreAllMocks() in afterEach clears
58+
// the mock implementation, so we must re-set it each test.
59+
mockMaybeSingle.mockResolvedValue({
60+
data: { id: "ws-uuid-123" },
61+
error: null,
62+
});
5763
fetchMock = vi.fn().mockResolvedValue({
5864
ok: true,
5965
json: async () => ({ results: [] }),
@@ -279,13 +285,12 @@ describe("PageSearch", () => {
279285
});
280286

281287
it("shows empty state even when aborted fetch finally block races with new cycle", async () => {
282-
// Regression test for the microtask ordering bug: when the debounce
283-
// effect re-runs (e.g. because workspaceId resolved), it aborts the
284-
// old controller and sets loading=true synchronously. The aborted
285-
// fetch's finally block runs later as a microtask. If the finally
286-
// block used signal.aborted to decide whether to clear loading, a
287-
// race could leave loading=true permanently. The generation counter
288-
// prevents this.
288+
// Regression test for the microtask ordering bug: when the effect
289+
// re-runs (e.g. because workspaceId resolved), it aborts the old
290+
// controller and sets loading=true synchronously. The aborted
291+
// fetch's finally block runs later as a microtask. The cancelled
292+
// flag (set in the effect cleanup) prevents stale callbacks from
293+
// updating state.
289294

290295
// First fetch resolves synchronously (simulating a fetch that
291296
// completes at the same tick the abort fires)
@@ -623,12 +628,101 @@ describe("PageSearch", () => {
623628
expect(searchResults.querySelectorAll(".animate-pulse").length).toBe(0);
624629
});
625630

631+
it("cancelled flag prevents stale finally block from blocking new cycle (#192)", async () => {
632+
// Regression test for #192: the generation counter pattern could
633+
// leave searchStatus stuck at "loading" if the effect re-ran between
634+
// fetch start and completion. The cancelled flag fixes this: it's
635+
// set once in cleanup and stays true, so the stale finally block is
636+
// discarded and the new cycle completes independently.
637+
638+
// First fetch hangs, second resolves immediately.
639+
let resolveFetch1: (value: Response) => void;
640+
const fetchCalls: string[] = [];
641+
fetchMock.mockImplementation(
642+
(url: string | URL | Request, init?: RequestInit) => {
643+
const urlStr = typeof url === "string" ? url : url.toString();
644+
fetchCalls.push(urlStr);
645+
const signal = init?.signal;
646+
if (fetchCalls.length === 1) {
647+
return new Promise<Response>((resolve, reject) => {
648+
resolveFetch1 = resolve;
649+
signal?.addEventListener("abort", () => {
650+
reject(new DOMException("Aborted", "AbortError"));
651+
});
652+
});
653+
}
654+
return Promise.resolve(
655+
new Response(JSON.stringify({ results: [] }), {
656+
status: 200,
657+
headers: { "Content-Type": "application/json" },
658+
}),
659+
);
660+
},
661+
);
662+
663+
render(<PageSearch />);
664+
665+
// Wait for workspace resolution
666+
await act(async () => {
667+
await vi.advanceTimersByTimeAsync(50);
668+
});
669+
670+
const input = screen.getByRole("combobox", { name: /search pages/i });
671+
672+
// Set the first query
673+
await act(async () => {
674+
fireEvent.focus(input);
675+
fireEvent.change(input, { target: { value: "first" } });
676+
});
677+
678+
// Advance past debounce — first fetch fires (hangs)
679+
await act(async () => {
680+
await vi.advanceTimersByTimeAsync(350);
681+
});
682+
683+
expect(fetchCalls.length).toBe(1);
684+
expect(fetchCalls[0]).toContain("first");
685+
686+
// Change query — effect cleanup cancels first cycle
687+
await act(async () => {
688+
fireEvent.change(input, { target: { value: "second" } });
689+
});
690+
691+
// Advance past debounce — second fetch fires and resolves
692+
await act(async () => {
693+
await vi.advanceTimersByTimeAsync(350);
694+
});
695+
696+
expect(fetchCalls.length).toBe(2);
697+
expect(fetchCalls[1]).toContain("second");
698+
699+
// Now resolve the first (stale) fetch — its .then() runs but
700+
// cancelledRef is true for that cycle, so it's discarded.
701+
await act(async () => {
702+
resolveFetch1!(
703+
new Response(JSON.stringify({ results: [] }), {
704+
status: 200,
705+
headers: { "Content-Type": "application/json" },
706+
}),
707+
);
708+
await vi.advanceTimersByTimeAsync(50);
709+
});
710+
711+
// The empty state should be visible (from the second fetch)
712+
expect(
713+
screen.getByText("No pages match your search"),
714+
).toBeInTheDocument();
715+
716+
// No skeletons should be stuck
717+
const searchResults = screen.getByRole("listbox");
718+
expect(searchResults.querySelectorAll(".animate-pulse").length).toBe(0);
719+
});
720+
626721
it("never leaves skeletons stuck when workspace resolves mid-debounce (#181)", async () => {
627-
// Regression test for #181: the two-effect architecture (debounce +
628-
// re-trigger) could leave searchStatus stuck at "loading" when the
629-
// workspace resolved at specific timings. The unified effect approach
630-
// eliminates this by handling workspace resolution as a dependency
631-
// change that naturally re-runs the effect.
722+
// Regression test for #181/#192: workspace resolution mid-debounce
723+
// must not leave searchStatus stuck at "loading". The cancelled flag
724+
// ensures the old cycle's callbacks are discarded and the new cycle
725+
// completes normally.
632726

633727
// Make workspace resolution resolve after 150ms (mid-debounce)
634728
mockMaybeSingle.mockReturnValue(

src/components/sidebar/page-search.tsx

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,13 @@ export function PageSearch() {
4646
const inputRef = useRef<HTMLInputElement>(null);
4747
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
4848
const abortRef = useRef<AbortController | null>(null);
49-
// Generation counter: incremented each search cycle so stale async
50-
// callbacks never update state. Immune to microtask ordering because
51-
// the counter is incremented synchronously before any async work.
52-
const searchGenRef = useRef(0);
49+
// Cancelled flag: set to true by the effect cleanup so stale async
50+
// callbacks (from aborted fetches) never update state. Unlike the
51+
// generation counter this replaces, a boolean flag cannot be
52+
// "incremented past" the expected value by a concurrent effect run —
53+
// once set to true it stays true, guaranteeing the finally block
54+
// always transitions to "done" for the active cycle.
55+
const cancelledRef = useRef(false);
5356

5457
// Register the search input ref so the sidebar context can focus it via ⌘+K
5558
useEffect(() => {
@@ -97,33 +100,39 @@ export function PageSearch() {
97100
}, [params.workspaceSlug]);
98101

99102
// Stable search function — accepts wsId as a parameter so the
100-
// callback identity never changes.
103+
// callback identity never changes. Uses cancelledRef to discard
104+
// stale results instead of a generation counter.
101105
const search = useCallback(
102-
async (q: string, wsId: string, gen: number, signal: AbortSignal) => {
106+
async (q: string, wsId: string, signal: AbortSignal) => {
103107
try {
104108
const response = await fetch(
105109
`/api/search?q=${encodeURIComponent(q.trim())}&workspace_id=${encodeURIComponent(wsId)}`,
106110
{ signal },
107111
);
108-
if (searchGenRef.current !== gen) return;
112+
if (cancelledRef.current) return;
109113
if (response.ok) {
110114
const data = (await response.json()) as { results: SearchResult[] };
111-
if (searchGenRef.current !== gen) return;
115+
if (cancelledRef.current) return;
112116
setResults(data.results);
113117
setSelectedIndex(0);
114118
} else {
115119
setResults([]);
116120
}
117121
} catch (error) {
118-
if (searchGenRef.current !== gen) return;
122+
if (cancelledRef.current) return;
119123
// AbortErrors are expected when the user types quickly — only
120124
// capture genuine failures.
121125
if (!(error instanceof DOMException && error.name === "AbortError")) {
122126
Sentry.captureException(error);
123127
}
124128
setResults([]);
125129
} finally {
126-
if (searchGenRef.current === gen) {
130+
// Always transition to "done" unless this cycle was cancelled.
131+
// The cancelled flag is set once in cleanup and stays true,
132+
// so there is no race where a concurrent effect run can
133+
// prevent this transition (unlike the generation counter
134+
// pattern that caused issues #118–#192).
135+
if (!cancelledRef.current) {
127136
setSearchStatus("done");
128137
}
129138
}
@@ -133,9 +142,7 @@ export function PageSearch() {
133142

134143
// Unified debounced search effect. Depends on query, workspaceId,
135144
// and workspaceResolved so it naturally re-runs when the workspace
136-
// resolves — no separate re-trigger effect needed. This eliminates
137-
// the class of race conditions caused by two effects competing over
138-
// the same abort controller and generation counter (#178, #181).
145+
// resolves — no separate re-trigger effect needed.
139146
useEffect(() => {
140147
if (debounceRef.current) {
141148
clearTimeout(debounceRef.current);
@@ -146,15 +153,16 @@ export function PageSearch() {
146153
abortRef.current = null;
147154
}
148155

156+
// Mark the previous cycle as cancelled so its async callbacks
157+
// (catch/finally) won't update state. Then reset for this cycle.
158+
cancelledRef.current = true;
159+
149160
if (!query.trim()) {
150-
searchGenRef.current += 1;
151161
setResults([]);
152162
setSearchStatus("idle");
153163
return;
154164
}
155165

156-
const gen = ++searchGenRef.current;
157-
158166
// If workspace hasn't resolved yet, stay in loading state.
159167
// When it resolves, this effect re-runs and fires the search.
160168
if (!workspaceResolved) {
@@ -170,17 +178,20 @@ export function PageSearch() {
170178
return;
171179
}
172180

181+
// Reset cancelled for this new search cycle.
182+
cancelledRef.current = false;
173183
setSearchStatus("loading");
174184

175185
const controller = new AbortController();
176186
abortRef.current = controller;
177187

178188
debounceRef.current = setTimeout(() => {
179189
debounceRef.current = null;
180-
search(query, workspaceId, gen, controller.signal);
190+
search(query, workspaceId, controller.signal);
181191
}, 300);
182192

183193
return () => {
194+
cancelledRef.current = true;
184195
if (debounceRef.current) {
185196
clearTimeout(debounceRef.current);
186197
debounceRef.current = null;

supabase/migrations/20260417165531_create_page_images_bucket.sql

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
-- Create the page-images storage bucket for editor image uploads.
22
-- Public bucket so images can be served without auth tokens.
3+
-- Wrapped in a DO block because `ON CONFLICT` can be lost when the
4+
-- Supabase CLI splits statements during `db reset`.
35

4-
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
5-
VALUES (
6-
'page-images',
7-
'page-images',
8-
true,
9-
5242880, -- 5 MB
10-
ARRAY['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
11-
)
12-
ON CONFLICT (id) DO NOTHING;
6+
DO $$ BEGIN
7+
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
8+
VALUES (
9+
'page-images',
10+
'page-images',
11+
true,
12+
5242880, -- 5 MB
13+
ARRAY['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
14+
);
15+
EXCEPTION WHEN unique_violation THEN NULL;
16+
END $$;
1317

1418
-- Authenticated users can upload images
1519
DO $$ BEGIN

0 commit comments

Comments
 (0)