Skip to content

Commit ef4471b

Browse files
committed
refactor: only update visible charts, with overscan
1 parent 06fba80 commit ef4471b

9 files changed

Lines changed: 360 additions & 25 deletions

File tree

src/hooks/useAdaptivePoll.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useRef, useState } from "react"
2+
import { sleep } from "../utils/sleep"
23

34
type AdaptivePollLoopOptions = {
45
fetchFn: () => Promise<void>
@@ -11,15 +12,6 @@ type AdaptivePollLoopOptions = {
1112
onIntervalChange?: (intervalMs: number) => void
1213
}
1314

14-
const sleep = (ms: number, signal: AbortSignal) =>
15-
new Promise<void>((resolve, reject) => {
16-
const timeoutId = setTimeout(resolve, ms)
17-
signal.addEventListener("abort", () => {
18-
clearTimeout(timeoutId)
19-
reject(new DOMException("Aborted", "AbortError"))
20-
})
21-
})
22-
2315
export const runAdaptivePollLoop = async ({
2416
fetchFn,
2517
signal,
@@ -54,11 +46,8 @@ export const runAdaptivePollLoop = async ({
5446
)
5547
onIntervalChange?.(nextInterval)
5648
}
57-
try {
58-
await sleep(nextInterval, signal)
59-
} catch {
60-
break
61-
}
49+
const aborted = await sleep(nextInterval, signal)
50+
if (aborted) break
6251
}
6352
}
6453

src/scenes/Editor/Notebook/chartRefresh/ChartRefreshContext.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const ChartRefreshContext = createContext<ChartRefreshEngine | null>(null)
1717

1818
export const ChartRefreshProvider = ChartRefreshContext.Provider
1919

20+
export const useChartRefresh = () => useContext(ChartRefreshContext)
21+
2022
export const useChartRefreshEngine = (options: {
2123
bufferId: number
2224
cells: NotebookCell[]

src/scenes/Editor/Notebook/chartRefresh/chartRefreshEngine.test.ts

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,14 @@ describe("ChartRefreshEngine", () => {
6060

6161
beforeEach(() => {
6262
vi.useFakeTimers()
63+
// clearAllMocks keeps implementations; reset the module mock explicitly so
64+
// one test's snapshot fixture can't hydrate another test's cells.
65+
vi.mocked(loadCellSnapshot).mockResolvedValue(undefined)
6366
deps = makeDeps()
64-
engine = new ChartRefreshEngine(BUFFER_ID, () => deps as ChartRefreshDeps)
67+
// Jitter off: tests assert exact fetch timing.
68+
engine = new ChartRefreshEngine(BUFFER_ID, () => deps as ChartRefreshDeps, {
69+
initialFetchJitterMs: 0,
70+
})
6571
engine.attach()
6672
})
6773

@@ -273,6 +279,137 @@ describe("ChartRefreshEngine", () => {
273279
expect(deps.mirrorCellResult).toHaveBeenCalledTimes(1)
274280
})
275281

282+
it("does not poll a cell that is hidden before it enters draw mode", async () => {
283+
// Given the observer reported the cell offscreen before it became a chart
284+
engine.setVisible("c1", false)
285+
286+
// When the engine syncs the polling draw cell
287+
engine.sync([drawCell("c1", "select 1", "1s")])
288+
await flushAsync()
289+
290+
// Then no fetch happens no matter how much time passes
291+
await vi.advanceTimersByTimeAsync(10_000)
292+
expect(deps.executeSingle).not.toHaveBeenCalled()
293+
})
294+
295+
it("catches up immediately on reveal when the cell never fetched", async () => {
296+
// Given a hidden, never-fetched polling cell
297+
engine.setVisible("c1", false)
298+
engine.sync([drawCell("c1", "select 1", "1s")])
299+
await flushAsync()
300+
expect(deps.executeSingle).not.toHaveBeenCalled()
301+
302+
// When the cell scrolls into view
303+
engine.setVisible("c1", true)
304+
await flushAsync()
305+
306+
// Then it fetches immediately and keeps polling
307+
expect(deps.executeSingle).toHaveBeenCalledTimes(1)
308+
await vi.advanceTimersByTimeAsync(1000)
309+
expect(deps.executeSingle).toHaveBeenCalledTimes(2)
310+
})
311+
312+
it("pauses polling when the cell scrolls out of view", async () => {
313+
// Given a visible polling cell that has fetched once
314+
engine.sync([drawCell("c1", "select 1", "1s")])
315+
await flushAsync()
316+
expect(deps.executeSingle).toHaveBeenCalledTimes(1)
317+
318+
// When it scrolls out of view
319+
engine.setVisible("c1", false)
320+
321+
// Then polling stops while its data stays intact
322+
await vi.advanceTimersByTimeAsync(10_000)
323+
expect(deps.executeSingle).toHaveBeenCalledTimes(1)
324+
expect(engine.getState("c1")?.results).toHaveLength(1)
325+
})
326+
327+
it("skips the reveal catch-up when the data is still fresh", async () => {
328+
// Given a cell hidden right after a fetch
329+
engine.sync([drawCell("c1", "select 1", "1s")])
330+
await flushAsync()
331+
expect(deps.executeSingle).toHaveBeenCalledTimes(1)
332+
engine.setVisible("c1", false)
333+
334+
// When it is revealed again well within its refresh interval
335+
await vi.advanceTimersByTimeAsync(100)
336+
engine.setVisible("c1", true)
337+
await flushAsync()
338+
339+
// Then there is no immediate refetch — the next one waits a full interval
340+
expect(deps.executeSingle).toHaveBeenCalledTimes(1)
341+
await vi.advanceTimersByTimeAsync(1000)
342+
expect(deps.executeSingle).toHaveBeenCalledTimes(2)
343+
})
344+
345+
it("defers an auto-refresh-off cell's initial fetch to its first reveal", async () => {
346+
// Given a hidden cell with auto-refresh off
347+
engine.setVisible("c1", false)
348+
engine.sync([drawCell("c1", "select 1", false)])
349+
await flushAsync()
350+
expect(deps.executeSingle).not.toHaveBeenCalled()
351+
352+
// When it is revealed for the first time
353+
engine.setVisible("c1", true)
354+
await flushAsync()
355+
356+
// Then it fetches exactly once
357+
expect(deps.executeSingle).toHaveBeenCalledTimes(1)
358+
359+
// And hiding and revealing it again does not refetch settled data
360+
engine.setVisible("c1", false)
361+
engine.setVisible("c1", true)
362+
await flushAsync()
363+
expect(deps.executeSingle).toHaveBeenCalledTimes(1)
364+
})
365+
366+
it("bounds concurrent fetches across cells", async () => {
367+
// Given an engine capped at one in-flight fetch and a slow first query
368+
engine.destroy()
369+
engine = new ChartRefreshEngine(BUFFER_ID, () => deps as ChartRefreshDeps, {
370+
initialFetchJitterMs: 0,
371+
maxConcurrentFetches: 1,
372+
})
373+
engine.attach()
374+
let releaseFirst!: (value: QueryExecResult) => void
375+
deps.executeSingle
376+
.mockImplementationOnce(
377+
() => new Promise<QueryExecResult>((res) => (releaseFirst = res)),
378+
)
379+
.mockImplementation((sql: string) => Promise.resolve(dqlResult(sql)))
380+
381+
// When two cells want to fetch at the same time
382+
engine.sync([
383+
drawCell("c1", "select 1", false),
384+
drawCell("c2", "select 2", false),
385+
])
386+
await flushAsync()
387+
388+
// Then only the first is in flight; the second waits its turn
389+
expect(deps.executeSingle).toHaveBeenCalledTimes(1)
390+
releaseFirst(dqlResult("select 1"))
391+
await flushAsync()
392+
expect(deps.executeSingle).toHaveBeenCalledTimes(2)
393+
})
394+
395+
it("spreads a loop's first fetch by the configured jitter", async () => {
396+
// Given an engine with 300ms of initial jitter
397+
engine.destroy()
398+
engine = new ChartRefreshEngine(BUFFER_ID, () => deps as ChartRefreshDeps, {
399+
initialFetchJitterMs: 300,
400+
})
401+
engine.attach()
402+
403+
// When a polling cell syncs
404+
engine.sync([drawCell("c1", "select 1", "1s")])
405+
await flushAsync()
406+
407+
// Then the first fetch waits for the jitter window instead of firing at t=0
408+
expect(deps.executeSingle).not.toHaveBeenCalled()
409+
await vi.advanceTimersByTimeAsync(300)
410+
expect(deps.executeSingle).toHaveBeenCalledTimes(1)
411+
})
412+
276413
it("publishes loading state for the cell toolbar", async () => {
277414
// Given a listener on the chart loading event
278415
const events: Array<{ loading: boolean; refreshing: boolean }> = []

0 commit comments

Comments
 (0)