Skip to content

Commit ac0a1a4

Browse files
Implement liveQueryExchange to prevent duplicate live query execution
- Add liveQueryExchange that tracks operations by key + variables hash - Prevents duplicate execution of live queries with identical parameters - Allows re-execution when variables change or after teardown - Add comprehensive tests for exchange behavior - Update GadgetConnection to use the new exchange - Add React test cases for live query re-execution scenarios Addresses feedback from PR #864 by moving exchange to separate file with proper documentation and comprehensive test coverage. Co-Authored-By: Harry Brundage <[email protected]>
1 parent 4920d81 commit ac0a1a4

File tree

5 files changed

+446
-15
lines changed

5 files changed

+446
-15
lines changed
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { jest } from "@jest/globals";
2+
import type { Operation } from "@urql/core";
3+
import { gql, makeOperation } from "@urql/core";
4+
import { fromArray, pipe, toArray } from "wonka";
5+
import { liveQueryExchange } from "../../src/exchanges/liveQueryExchange.js";
6+
7+
describe("liveQueryExchange", () => {
8+
const mockForward = jest.fn((ops$: any) => ops$);
9+
let exchange: any;
10+
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
exchange = liveQueryExchange({ forward: mockForward, client: {} as any, dispatchDebug: jest.fn() });
14+
});
15+
16+
const createLiveQuery = (key = 1, variables: any = {}): Operation => {
17+
return makeOperation(
18+
"query",
19+
{
20+
key,
21+
query: gql`
22+
query TestQuery @live {
23+
users {
24+
id
25+
name
26+
}
27+
}
28+
`,
29+
variables,
30+
},
31+
{} as any
32+
);
33+
};
34+
35+
const createRegularQuery = (key = 2): Operation => {
36+
return makeOperation(
37+
"query",
38+
{
39+
key,
40+
query: gql`
41+
query TestQuery {
42+
users {
43+
id
44+
name
45+
}
46+
}
47+
`,
48+
variables: {},
49+
},
50+
{} as any
51+
);
52+
};
53+
54+
const createTeardown = (key = 1, variables: any = {}): Operation => {
55+
return makeOperation(
56+
"teardown",
57+
{
58+
key,
59+
query: gql`
60+
query TestQuery @live {
61+
users {
62+
id
63+
name
64+
}
65+
}
66+
`,
67+
variables,
68+
},
69+
{} as any
70+
);
71+
};
72+
73+
test("allows first live query to pass through", () => {
74+
const liveQuery = createLiveQuery(1);
75+
const operations$ = fromArray([liveQuery]);
76+
77+
const result$ = exchange(operations$);
78+
const results = pipe(result$, toArray);
79+
80+
expect(results).toHaveLength(1);
81+
expect(results[0]).toBe(liveQuery);
82+
});
83+
84+
test("blocks duplicate live query execution with same variables", () => {
85+
const liveQuery1 = createLiveQuery(1, { filter: { id: "1" } });
86+
const liveQuery2 = createLiveQuery(1, { filter: { id: "1" } }); // Same key and variables
87+
const operations$ = fromArray([liveQuery1, liveQuery2]);
88+
89+
const result$ = exchange(operations$);
90+
const results = pipe(result$, toArray);
91+
92+
expect(results).toHaveLength(1);
93+
expect(results[0]).toBe(liveQuery1);
94+
});
95+
96+
test("allows regular queries to pass through unchanged", () => {
97+
const regularQuery1 = createRegularQuery(1);
98+
const regularQuery2 = createRegularQuery(1); // Same key but not live
99+
const operations$ = fromArray([regularQuery1, regularQuery2]);
100+
101+
const result$ = exchange(operations$);
102+
const results = pipe(result$, toArray);
103+
104+
expect(results).toHaveLength(2);
105+
expect(results[0]).toBe(regularQuery1);
106+
expect(results[1]).toBe(regularQuery2);
107+
});
108+
109+
test("allows teardown operations to pass through", () => {
110+
const liveQuery = createLiveQuery(1);
111+
const teardown = createTeardown(1);
112+
const operations$ = fromArray([liveQuery, teardown]);
113+
114+
const result$ = exchange(operations$);
115+
const results = pipe(result$, toArray);
116+
117+
expect(results).toHaveLength(2);
118+
expect(results[0]).toBe(liveQuery);
119+
expect(results[1]).toBe(teardown);
120+
});
121+
122+
test("allows live query re-execution after teardown", () => {
123+
const variables = { filter: { id: "1" } };
124+
const liveQuery1 = createLiveQuery(1, variables);
125+
const teardown = createTeardown(1, variables); // Same variables as the query
126+
const liveQuery2 = createLiveQuery(1, variables); // Same key and variables after teardown
127+
const operations$ = fromArray([liveQuery1, teardown, liveQuery2]);
128+
129+
const result$ = exchange(operations$);
130+
const results = pipe(result$, toArray);
131+
132+
expect(results).toHaveLength(3);
133+
expect(results[0]).toBe(liveQuery1);
134+
expect(results[1]).toBe(teardown);
135+
expect(results[2]).toBe(liveQuery2);
136+
});
137+
138+
test("handles multiple live queries with different keys", () => {
139+
const liveQuery1 = createLiveQuery(1, { filter: { id: "1" } });
140+
const liveQuery2 = createLiveQuery(2, { filter: { id: "2" } });
141+
const operations$ = fromArray([liveQuery1, liveQuery2]);
142+
143+
const result$ = exchange(operations$);
144+
const results = pipe(result$, toArray);
145+
146+
expect(results).toHaveLength(2);
147+
expect(results[0]).toBe(liveQuery1);
148+
expect(results[1]).toBe(liveQuery2);
149+
});
150+
151+
test("allows non-query operations for live queries to pass through", () => {
152+
const liveQuery = createLiveQuery(1);
153+
const mutation = makeOperation(
154+
"mutation",
155+
{
156+
key: 1,
157+
query: gql`
158+
mutation TestMutation @live {
159+
updateUser(id: "1", user: { name: "test" }) {
160+
id
161+
name
162+
}
163+
}
164+
`,
165+
variables: {},
166+
},
167+
{} as any
168+
);
169+
const operations$ = fromArray([liveQuery, mutation]);
170+
171+
const result$ = exchange(operations$);
172+
const results = pipe(result$, toArray);
173+
174+
expect(results).toHaveLength(2);
175+
expect(results[0]).toBe(liveQuery);
176+
expect(results[1]).toBe(mutation);
177+
});
178+
});

packages/api-client-core/src/GadgetConnection.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { BrowserSessionStorageType } from "./ClientOptions.js";
1212
import { GadgetTransaction, TransactionRolledBack } from "./GadgetTransaction.js";
1313
import type { BrowserStorage } from "./InMemoryStorage.js";
1414
import { InMemoryStorage } from "./InMemoryStorage.js";
15+
import { liveQueryExchange } from "./exchanges/liveQueryExchange.js";
1516
import { operationNameExchange } from "./exchanges/operationNameExchange.js";
1617
import { addUrlParams, urlParamExchange } from "./exchanges/urlParamExchange.js";
1718
import {
@@ -368,6 +369,7 @@ export class GadgetConnection {
368369
// apply urql's default caching behaviour when client side (but skip it server side)
369370
if (typeof window != "undefined") {
370371
exchanges.push(cacheExchange);
372+
exchanges.push(liveQueryExchange);
371373
}
372374
exchanges.push(
373375
...this.exchanges.beforeAsync,
@@ -442,17 +444,6 @@ export class GadgetConnection {
442444
});
443445
(client as any)[$gadgetConnection] = this;
444446

445-
const reexecuteOperation = client.reexecuteOperation.bind(client);
446-
client.reexecuteOperation = (operation) => {
447-
if (operation.query.definitions.some(isLiveQueryOperationDefinitionNode)) {
448-
// live queries don't cleanup properly when reexecuted, plus
449-
// they should never need to be reexecuted since they receive
450-
// updates from the server, so we noop here
451-
return;
452-
}
453-
reexecuteOperation(operation);
454-
};
455-
456447
return client;
457448
}
458449

@@ -665,6 +656,6 @@ const getLiveDirectiveNode = (input: DefinitionNode): Maybe<DirectiveNode> => {
665656
return input.directives?.find((d) => d.name.value === "live");
666657
};
667658

668-
const isLiveQueryOperationDefinitionNode = (input: DefinitionNode): input is OperationDefinitionNode => {
659+
export const isLiveQueryOperationDefinitionNode = (input: DefinitionNode): input is OperationDefinitionNode => {
669660
return !!getLiveDirectiveNode(input);
670661
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Exchange, Operation } from "@urql/core";
2+
import { filter, merge, pipe, tap } from "wonka";
3+
import { isLiveQueryOperationDefinitionNode } from "../GadgetConnection.js";
4+
5+
/**
6+
* Exchange that prevents duplicate execution of live queries while allowing proper teardown and re-establishment.
7+
*
8+
* Live queries are long-running special subscriptions that receive real-time updates from the server. When mutations occur, urql's cache exchange tries to re-execute any mounted queries that could be affected and so re-executes live queries. Live queries automatically update their own data using their own persistent connection, so they shouldn't be re-executed if already mounted.
9+
*/
10+
export const liveQueryExchange: Exchange = ({ forward }) => {
11+
const executed = new Set<number>();
12+
13+
const getOperationId = (op: Operation<any, any>) => {
14+
return op.key;
15+
};
16+
17+
return (operations$) => {
18+
const notLive = pipe(
19+
operations$,
20+
filter((op) => !op.query.definitions.some(isLiveQueryOperationDefinitionNode))
21+
);
22+
23+
const live = pipe(
24+
operations$,
25+
filter((op) => op.query.definitions.some(isLiveQueryOperationDefinitionNode)),
26+
filter((op) => {
27+
const opId = getOperationId(op);
28+
return !executed.has(opId) || op.kind !== "query";
29+
}),
30+
tap((op) => {
31+
const opId = getOperationId(op);
32+
if (op.kind === "query") {
33+
executed.add(opId);
34+
} else if (op.kind === "teardown") {
35+
executed.delete(opId);
36+
}
37+
})
38+
);
39+
40+
return forward(merge([live, notLive]));
41+
};
42+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@gadgetinc/api-client-core": patch
3+
"@gadgetinc/react": patch
4+
---
5+
6+
Ensure live queries correctly re-execute when input variables change
7+
8+
Previously, mounted live queries in React would erroneously not re-connect to the server when their input variables changed, like the filter, sort, or selection. The websocket would confusingly stay open, but the client side code listening for changes would stop listening and not restart. This has now been fixed and live queries will correctly re-establish a new websocket connection with new variables when the variables change.

0 commit comments

Comments
 (0)