Skip to content

Commit ccbec00

Browse files
committed
fix(showcase-dashboard): disable PB SDK autocancel on concurrent initial fetch
The PocketBase JS SDK derives a request key from method+path and auto-cancels any in-flight request that shares it. useLiveStatus's fetchInitial fans pages 2..N out concurrently at the same path (/api/collections/status/records), so every page after the first cancelled its predecessor — the cancelled promises rejected, Promise.all rejected, and the hook dropped to OFFLINE. Pass requestKey:null on the page-1 read, every fan-out page, and the heartbeat ping so all concurrent same-path reads complete. Add a real-SDK regression test (the mocked suite never exercised the SDK so it missed this): an in-process http server replays paged status records with per-page latency, the production getPb() client drives the real hook, and it asserts "live" with all rows. RED without requestKey:null.
1 parent cd45c87 commit ccbec00

2 files changed

Lines changed: 196 additions & 3 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/**
2+
* REAL-SDK regression test for the PocketBase auto-cancellation bug.
3+
*
4+
* The sibling `useLiveStatus.test.tsx` mocks `../lib/pb`, so it never
5+
* exercises the real PocketBase JS SDK and therefore MISSED this bug: the
6+
* SDK derives a request key from `method + path` and AUTO-CANCELS any
7+
* in-flight request that shares it. `fetchInitial` fans pages 2..N out
8+
* CONCURRENTLY at the SAME path (`/api/collections/status/records`), so
9+
* every page after the first would cancel its predecessor — the cancelled
10+
* promises reject, `Promise.all` rejects, and the hook drops to OFFLINE.
11+
*
12+
* This test stands up a real in-process Node http server that serves the
13+
* status records endpoint paged with a per-page delay (so multiple fan-out
14+
* pages are genuinely in flight at once), points the PRODUCTION `getPb()`
15+
* client at it, drives the REAL hook, and asserts it reaches "live" with
16+
* ALL pages' rows. It is RED if `requestKey: null` is removed from the list
17+
* options and GREEN with it.
18+
*
19+
* NOTE: this file deliberately does NOT mock `../lib/pb` — it uses the real
20+
* SDK against a real socket. EventSource + localStorage are stubbed because
21+
* jsdom lacks the realtime/SSE plumbing the subscribe() path needs; we only
22+
* assert the initial paged fetch here (the bug lives in fetchInitial, not in
23+
* the SSE subscribe path).
24+
*/
25+
import {
26+
describe,
27+
it,
28+
expect,
29+
beforeAll,
30+
afterAll,
31+
beforeEach,
32+
afterEach,
33+
vi,
34+
} from "vitest";
35+
import { renderHook, waitFor } from "@testing-library/react";
36+
import { createServer } from "node:http";
37+
import type { Server } from "node:http";
38+
import type { AddressInfo } from "node:net";
39+
40+
// Total rows the fake server serves, spread across PB's 500-row page clamp.
41+
// 1300 rows → 3 pages (500 + 500 + 300): page 1 reports totalPages, then the
42+
// hook fans out pages 2 & 3 concurrently — exactly the scenario the SDK's
43+
// auto-cancellation breaks.
44+
const TOTAL_ROWS = 1300;
45+
const PER_PAGE_CLAMP = 500;
46+
// Per-page artificial latency. Long enough that pages 2 & 3 are GENUINELY
47+
// in flight simultaneously, so the SDK's same-path auto-cancel actually
48+
// fires (a zero-delay server might resolve page 2 before page 3 is even
49+
// dispatched, masking the bug).
50+
const PAGE_DELAY_MS = 40;
51+
52+
function makeRow(i: number): Record<string, unknown> {
53+
const id = `r${String(i).padStart(4, "0")}`;
54+
return {
55+
id,
56+
key: `smoke:int/f${id}`,
57+
dimension: "smoke",
58+
state: "green",
59+
signal: {},
60+
observed_at: "2026-04-20T00:00:00Z",
61+
transitioned_at: "2026-04-20T00:00:00Z",
62+
fail_count: 0,
63+
first_failure_at: null,
64+
};
65+
}
66+
67+
const ALL_ROWS = Array.from({ length: TOTAL_ROWS }, (_, i) => makeRow(i));
68+
69+
/**
70+
* Minimal PocketBase-compatible list endpoint. Honours `?page=`/`?perPage=`
71+
* (clamped to 500, like real PB) and delays each response by PAGE_DELAY_MS so
72+
* concurrent fan-out pages overlap on the wire.
73+
*/
74+
function startPbServer(): Promise<{ server: Server; url: string }> {
75+
const server = createServer((req, res) => {
76+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
77+
if (!url.pathname.startsWith("/api/collections/status/records")) {
78+
res.statusCode = 404;
79+
res.end(JSON.stringify({ message: "not found" }));
80+
return;
81+
}
82+
const page = Number(url.searchParams.get("page") ?? "1");
83+
const perPage = Math.min(
84+
Number(url.searchParams.get("perPage") ?? String(PER_PAGE_CLAMP)),
85+
PER_PAGE_CLAMP,
86+
);
87+
const start = (page - 1) * perPage;
88+
const items = ALL_ROWS.slice(start, start + perPage);
89+
const body = JSON.stringify({
90+
page,
91+
perPage,
92+
totalItems: TOTAL_ROWS,
93+
totalPages: Math.max(1, Math.ceil(TOTAL_ROWS / perPage)),
94+
items,
95+
});
96+
// Delay so concurrent fan-out pages are genuinely in flight together.
97+
setTimeout(() => {
98+
res.setHeader("content-type", "application/json");
99+
res.end(body);
100+
}, PAGE_DELAY_MS);
101+
});
102+
return new Promise((resolve) => {
103+
// Bind to 127.0.0.1 (not localhost) — the PB SDK warns localhost can
104+
// mis-resolve to ::1 and refuse the connection in Node.
105+
server.listen(0, "127.0.0.1", () => {
106+
const { port } = server.address() as AddressInfo;
107+
resolve({ server, url: `http://127.0.0.1:${port}` });
108+
});
109+
});
110+
}
111+
112+
let server: Server;
113+
let baseUrl: string;
114+
115+
beforeAll(async () => {
116+
const started = await startPbServer();
117+
server = started.server;
118+
baseUrl = started.url;
119+
});
120+
121+
afterAll(() => {
122+
server.close();
123+
});
124+
125+
beforeEach(() => {
126+
// jsdom has neither EventSource (PB realtime/SSE) nor a usable localStorage
127+
// for the SDK's auth store. Stub both so constructing/driving the real
128+
// PocketBase client doesn't blow up — we never exercise the SSE subscribe
129+
// path here (the bug under test is in the initial paged fetch).
130+
(globalThis as unknown as { EventSource: unknown }).EventSource = class {
131+
close(): void {}
132+
addEventListener(): void {}
133+
removeEventListener(): void {}
134+
};
135+
const store = new Map<string, string>();
136+
Object.defineProperty(globalThis, "localStorage", {
137+
configurable: true,
138+
value: {
139+
getItem: (k: string) => store.get(k) ?? null,
140+
setItem: (k: string, v: string) => store.set(k, v),
141+
removeItem: (k: string) => store.delete(k),
142+
clear: () => store.clear(),
143+
},
144+
});
145+
// Point the production runtime-config reader at our fake server, then reset
146+
// the pb module so getPb() rebuilds its singleton against this URL.
147+
(
148+
globalThis as unknown as { window: Window & typeof globalThis }
149+
).window.__SHOWCASE_CONFIG__ = {
150+
pocketbaseUrl: baseUrl,
151+
shellUrl: baseUrl,
152+
opsBaseUrl: baseUrl,
153+
};
154+
vi.resetModules();
155+
});
156+
157+
afterEach(() => {
158+
vi.resetModules();
159+
});
160+
161+
describe("useLiveStatus (real PocketBase SDK — auto-cancellation regression)", () => {
162+
it("reaches live with ALL pages despite concurrent same-path fan-out", async () => {
163+
// Import AFTER resetModules + config injection so the hook closes over a
164+
// freshly-constructed pb singleton pointed at our fake server.
165+
const { useLiveStatus } = await import("./useLiveStatus");
166+
const { result } = renderHook(() => useLiveStatus("smoke"));
167+
168+
// Without `requestKey: null`, pages 2 & 3 share the page-1 auto request
169+
// key, get auto-cancelled, Promise.all rejects, and the hook lands in
170+
// "error" (or never reaches "live") instead. The fix lets every page
171+
// complete → all 1300 rows → "live".
172+
await waitFor(() => expect(result.current.status).toBe("live"), {
173+
timeout: 5000,
174+
});
175+
expect(result.current.rows).toHaveLength(TOTAL_ROWS);
176+
expect(result.current.error).toBeNull();
177+
}, 10000);
178+
});

showcase/shell-dashboard/src/hooks/useLiveStatus.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,13 @@ export function useLiveStatus(dimension?: string): UseLiveStatusResult {
224224
async function heartbeat(): Promise<void> {
225225
if (!alive || reconnecting) return;
226226
try {
227-
await pb.collection("status").getList(1, 1, { filter });
227+
// `requestKey: null` for the same reason as fetchInitial: this ping
228+
// hits the SAME path (`/api/collections/status/records`), so the
229+
// SDK's default auto-key would let a heartbeat and an in-flight
230+
// initial/fan-out read cancel each other.
231+
await pb
232+
.collection("status")
233+
.getList(1, 1, { filter, requestKey: null });
228234
} catch (err) {
229235
if (!alive) return;
230236
// SSE socket is probably dead too — re-establish the whole
@@ -266,9 +272,18 @@ export function useLiveStatus(dimension?: string): UseLiveStatusResult {
266272
// monotonic, so a row inserted in the brief fetch→subscribe window sorts
267273
// into a random position and is reconciled by the live SSE subscription
268274
// (which delivers all future deltas), not by this fetch.
275+
// `requestKey: null` DISABLES the PocketBase SDK's auto-cancellation.
276+
// By default the SDK derives a request key from the HTTP method + path
277+
// and auto-cancels any in-flight request that shares it. Our fan-out
278+
// fires pages 2..N at the SAME path (`/api/collections/status/records`)
279+
// concurrently, so every page after the first would cancel its
280+
// predecessor — the cancelled promises reject and `Promise.all` rejects,
281+
// dropping the whole hook to OFFLINE. Opting out per-request lets all
282+
// concurrent same-path reads complete. Forwarded to page 1 too so it
283+
// can't be cancelled by the fan-out either.
269284
const listOpts = filter
270-
? { filter, sort: INITIAL_SORT }
271-
: { sort: INITIAL_SORT };
285+
? { filter, sort: INITIAL_SORT, requestKey: null }
286+
: { sort: INITIAL_SORT, requestKey: null };
272287
const first = await pb
273288
.collection("status")
274289
.getList<StatusRow>(1, INITIAL_PAGE_SIZE, listOpts);

0 commit comments

Comments
 (0)