Skip to content

Commit 5496c6a

Browse files
fix(appkit-ui): stabilize useAnalyticsQuery params reference to avoid infinite refetch (#321)
* fix(appkit-ui): stabilize useAnalyticsQuery params reference useAnalyticsQuery treated `parameters` as a useMemo dep by reference. A typical call site passes a fresh object literal every render (`useAnalyticsQuery("k", { limit: sql.number(10) })`), which invalidated the payload memo and re-ran `start` -> infinite refetch. The workaround was for every consumer to wrap params in `useMemo` — a recurring footgun. Stabilize the params reference inside the hook using a useRef + shallow structural equality check. Analytics query params are produced by the `sql.*` builders and are always 1-level objects keyed to primitives, so shallow equality is sufficient and substantially cheaper than a full deep-equal. No public API change. Also considered: stable-stringify + cache-keying on the string. Rejected because params are tiny, structural equality is what users actually want, and TanStack Query / SWR ship the same approach. Updates the LLM guide which previously told consumers to always memoize params. Co-authored-by: Isaac Signed-off-by: James Broadhead <jamesbroadhead@gmail.com> * fix(appkit-ui): bump tsconfig lib from ES2021 to ES2022 The override added DOM lib but accidentally downleveled inherited ES2022 from the root tsconfig, breaking Object.hasOwn (used in the shallowEqualParams helper). Signed-off-by: James Broadhead <jamesbroadhead@gmail.com> * docs: drop obsolete useMemo guidance from llm-guide The hook now stabilizes parameters internally, so the bullet was just telling an LLM not to do something it wouldn't have done unprompted. Co-authored-by: Isaac Signed-off-by: James Broadhead <jamesbroadhead@gmail.com> --------- Signed-off-by: James Broadhead <jamesbroadhead@gmail.com>
1 parent 5f5ee05 commit 5496c6a

4 files changed

Lines changed: 154 additions & 4 deletions

File tree

docs/docs/development/llm-guide.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ This guide is designed to work even when you *do not* have access to the AppKit
3333

3434
- **Do not invent APIs**. If unsure, stick to the patterns shown in the documentation and only use documented exports from `@databricks/appkit` and `@databricks/appkit-ui`.
3535
- **`createApp()` is async**. Prefer **top-level `await createApp(...)`**. If you can't, use `void createApp(...)` and do not ignore promise rejection.
36-
- **Always memoize query parameters** passed to `useAnalyticsQuery` / charts to avoid refetch loops.
3736
- **Always handle loading/error/empty states** in UI (use `Skeleton`, error text, empty state).
3837
- **Always use `sql.*` helpers** for query parameters (do not pass raw strings/numbers unless the query expects none).
3938
- **Never construct SQL strings dynamically**. Use parameterized queries with `:paramName`.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { afterEach, describe, expect, test, vi } from "vitest";
3+
4+
// Mock connectSSE so the hook does not attempt a real network request.
5+
const mockConnectSSE = vi.fn().mockImplementation((_opts: unknown) => {
6+
// Return a never-resolving promise; tests don't need the result.
7+
return new Promise<void>(() => {});
8+
});
9+
10+
vi.mock("@/js", () => ({
11+
ArrowClient: {
12+
fetchArrow: vi.fn(),
13+
processArrowBuffer: vi.fn(),
14+
},
15+
connectSSE: (...args: unknown[]) => mockConnectSSE(...args),
16+
}));
17+
18+
// Stub useQueryHMR so we don't pull in import.meta.hot wiring.
19+
vi.mock("../use-query-hmr", () => ({
20+
useQueryHMR: () => {},
21+
}));
22+
23+
import { useAnalyticsQuery } from "../use-analytics-query";
24+
25+
describe("useAnalyticsQuery", () => {
26+
afterEach(() => {
27+
vi.clearAllMocks();
28+
});
29+
30+
test("does not refetch when params object is structurally equal across renders", () => {
31+
// Each render passes a fresh object literal — the common footgun.
32+
const { rerender } = renderHook(
33+
({ limit }: { limit: number }) =>
34+
// biome-ignore lint/suspicious/noExplicitAny: typed registry not available in tests
35+
useAnalyticsQuery("test_query" as any, { limit } as any),
36+
{ initialProps: { limit: 10 } },
37+
);
38+
39+
// Initial render triggers exactly one connection.
40+
expect(mockConnectSSE).toHaveBeenCalledTimes(1);
41+
42+
// Re-render with structurally-equal-but-new-reference params.
43+
rerender({ limit: 10 });
44+
rerender({ limit: 10 });
45+
rerender({ limit: 10 });
46+
47+
// Should NOT have refetched — the hook stabilized the params reference.
48+
expect(mockConnectSSE).toHaveBeenCalledTimes(1);
49+
});
50+
51+
test("does refetch when a param value actually changes", () => {
52+
const { rerender } = renderHook(
53+
({ limit }: { limit: number }) =>
54+
// biome-ignore lint/suspicious/noExplicitAny: typed registry not available in tests
55+
useAnalyticsQuery("test_query" as any, { limit } as any),
56+
{ initialProps: { limit: 10 } },
57+
);
58+
59+
expect(mockConnectSSE).toHaveBeenCalledTimes(1);
60+
61+
rerender({ limit: 20 });
62+
63+
expect(mockConnectSSE).toHaveBeenCalledTimes(2);
64+
});
65+
66+
test("does not refetch when params is undefined across renders", () => {
67+
const { rerender } = renderHook(() =>
68+
// biome-ignore lint/suspicious/noExplicitAny: typed registry not available in tests
69+
useAnalyticsQuery("test_query" as any),
70+
);
71+
72+
expect(mockConnectSSE).toHaveBeenCalledTimes(1);
73+
74+
rerender();
75+
rerender();
76+
77+
expect(mockConnectSSE).toHaveBeenCalledTimes(1);
78+
});
79+
80+
test("treats two empty object literals as equal", () => {
81+
const { rerender } = renderHook(() =>
82+
// biome-ignore lint/suspicious/noExplicitAny: typed registry not available in tests
83+
useAnalyticsQuery("test_query" as any, {} as any),
84+
);
85+
86+
expect(mockConnectSSE).toHaveBeenCalledTimes(1);
87+
88+
rerender();
89+
rerender();
90+
91+
expect(mockConnectSSE).toHaveBeenCalledTimes(1);
92+
});
93+
});

packages/appkit-ui/src/react/hooks/use-analytics-query.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,55 @@ import type {
1010
} from "./types";
1111
import { useQueryHMR } from "./use-query-hmr";
1212

13+
/**
14+
* Shallow structural equality for analytics query parameter objects.
15+
*
16+
* Analytics query parameters are produced by the `sql.*` builders and are
17+
* always plain objects keyed to primitive values (string | number | boolean
18+
* | null | undefined), so shallow equality is sufficient and substantially
19+
* cheaper than a full deep-equal.
20+
*/
21+
function shallowEqualParams(a: unknown, b: unknown): boolean {
22+
if (Object.is(a, b)) return true;
23+
if (
24+
a === null ||
25+
b === null ||
26+
typeof a !== "object" ||
27+
typeof b !== "object"
28+
) {
29+
return false;
30+
}
31+
const aKeys = Object.keys(a as Record<string, unknown>);
32+
const bKeys = Object.keys(b as Record<string, unknown>);
33+
if (aKeys.length !== bKeys.length) return false;
34+
for (const key of aKeys) {
35+
if (!Object.hasOwn(b, key)) return false;
36+
if (
37+
!Object.is(
38+
(a as Record<string, unknown>)[key],
39+
(b as Record<string, unknown>)[key],
40+
)
41+
) {
42+
return false;
43+
}
44+
}
45+
return true;
46+
}
47+
48+
/**
49+
* Stabilize a value's identity across renders when it is structurally equal
50+
* to the previous value. Used to make object-literal parameters safe to pass
51+
* directly to `useAnalyticsQuery` without forcing every consumer to wrap
52+
* params in `useMemo`.
53+
*/
54+
function useStableParams<T>(value: T): T {
55+
const ref = useRef<T>(value);
56+
if (!shallowEqualParams(ref.current, value)) {
57+
ref.current = value;
58+
}
59+
return ref.current;
60+
}
61+
1362
function getDevMode() {
1463
const url = new URL(window.location.href);
1564
const searchParams = url.searchParams;
@@ -79,9 +128,18 @@ export function useAnalyticsQuery<
79128
);
80129
}
81130

131+
// Stabilize the parameters reference across renders. Without this, a fresh
132+
// object literal at the call site (e.g. `useAnalyticsQuery("k", { limit: 10 })`)
133+
// would change identity every render, invalidating the `payload` memo and
134+
// re-running `start` -> infinite refetch loop.
135+
const stableParameters = useStableParams(parameters);
136+
82137
const payload = useMemo(() => {
83138
try {
84-
const serialized = JSON.stringify({ parameters, format });
139+
const serialized = JSON.stringify({
140+
parameters: stableParameters,
141+
format,
142+
});
85143
const sizeInBytes = new Blob([serialized]).size;
86144
if (sizeInBytes > maxParametersSize) {
87145
throw new Error(
@@ -94,7 +152,7 @@ export function useAnalyticsQuery<
94152
console.error("useAnalyticsQuery: Failed to serialize parameters", error);
95153
return null;
96154
}
97-
}, [parameters, format, maxParametersSize]);
155+
}, [stableParameters, format, maxParametersSize]);
98156

99157
const start = useCallback(() => {
100158
if (payload === null) {

packages/appkit-ui/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"compilerOptions": {
44
"outDir": "dist",
55
"jsx": "react-jsx",
6-
"lib": ["ES2021", "DOM"],
6+
"lib": ["ES2022", "DOM"],
77
"baseUrl": ".",
88
"paths": {
99
"@/*": ["./src/*"],

0 commit comments

Comments
 (0)