Skip to content

Commit 918439c

Browse files
authored
Add support for conditional query execution via function parameters (#39)
* refactor: make args parameter optional in useQuery function with empty object default * feat: add skip functionality to useQuery for conditional query execution * docs: add section on conditionally skipping queries * refactor: clean up code comments * docs: add troubleshooting guide for effect_in_teardown errors with useQuery * test: add e2e test for skipQuery functionality
1 parent 8b2b761 commit 918439c

File tree

5 files changed

+208
-55
lines changed

5 files changed

+208
-55
lines changed

README.md

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -65,30 +65,61 @@ and [Chat.svelte](src/routes/Chat.svelte) for how to use `useQuery()`
6565
Running a mutation looks like
6666

6767
```svelte
68-
<script>
69-
import { api } from "../../convex/_generated/api.js"; // depending on file location
70-
import { useConvexClient, useQuery } from "convex-svelte";
71-
const client = useConvexClient();
72-
73-
let toSend = $state('');
74-
let author = $state('me');
75-
76-
function onSubmit(e: SubmitEvent) {
77-
const data = Object.fromEntries(new FormData(e.target as HTMLFormElement).entries());
78-
client.mutation(api.messages.send, {
79-
author: data.author as string,
80-
body: data.body as string
81-
});
82-
}
68+
<script lang="ts">
69+
import { api } from '../../convex/_generated/api.js'; // depending on file location
70+
import { useConvexClient } from 'convex-svelte';
71+
const client = useConvexClient();
72+
73+
let toSend = $state('');
74+
let author = $state('me');
75+
76+
function handleSubmit(event: SubmitEvent) {
77+
event.preventDefault();
78+
79+
const data = Object.fromEntries(new FormData(event.target as HTMLFormElement).entries());
80+
client.mutation(api.messages.send, {
81+
author: data.author as string,
82+
body: data.body as string
83+
});
84+
}
8385
</script>
8486
85-
<form on:submit|preventDefault={onSubmit}>
86-
<input type="text" id="author" name="author" />
87-
<input type="text" id="body" name="body" bind:value={toSend} />
87+
<form onsubmit={handleSubmit}>
88+
<input type="text" name="author" bind:value={author} />
89+
<input type="text" name="body" bind:value={toSend} />
8890
<button type="submit" disabled={!toSend}>Send</button>
8991
</form>
9092
```
9193

94+
### Conditionally skipping queries
95+
96+
You can conditionally skip a query by returning the string `'skip'` from the arguments function.
97+
This is useful when a query depends on some condition, like authentication state or user input.
98+
99+
```svelte
100+
<script lang="ts">
101+
import { useQuery } from "convex-svelte";
102+
import { api } from "../convex/_generated/api.js";
103+
104+
let auth = $state({ isAuthenticated: true });
105+
106+
const activeUserResponse = useQuery(
107+
api.users.queries.getActiveUser,
108+
() => (auth.isAuthenticated ? {} : 'skip')
109+
);
110+
</script>
111+
112+
{#if activeUserResponse.isLoading}
113+
Loading user...
114+
{:else if activeUserResponse.error}
115+
Error: {activeUserResponse.error}
116+
{:else if activeUserResponse.data}
117+
Welcome, {activeUserResponse.data.name}!
118+
{/if}
119+
```
120+
121+
When a query is skipped, `isLoading` will be `false`, `error` will be `null`, and `data` will be `undefined`.
122+
92123
### Server-side rendering
93124

94125
`useQuery()` accepts an `initialData` option in its third argument.
@@ -130,6 +161,16 @@ export const load = (async () => {
130161

131162
Combining specifying `initialData` and either setting the `keepPreviousData` option to true or never modifying the arguments passed to a query should be enough to avoid ever seeing a loading state for a `useQuery()`.
132163

164+
### Troubleshooting
165+
166+
#### effect_in_teardown Error
167+
168+
If you encounter `effect_in_teardown` errors when using `useQuery` in components that can be conditionally rendered (like dialogs, modals, or popups), this is caused by wrapping `useQuery` in a `$derived` block that depends on reactive state.
169+
170+
When `useQuery` is wrapped in `$derived`, state changes during component cleanup can trigger re-evaluation of the `$derived`, which attempts to create a new `useQuery` instance. Since `useQuery` internally creates a `$effect`, and effects cannot be created during cleanup, this throws an error.
171+
172+
Use [Conditionally skipping queries](#conditionally-skipping-queries) instead. By calling `useQuery` unconditionally at the top level and passing a function that returns `'skip'`, the function is evaluated inside `useQuery`'s own effect tracking, preventing query recreation during cleanup.
173+
133174
### Deploying a Svelte App
134175

135176
In production build pipelines use the build command

e2e/skipQuery.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test('skipQuery prevents query execution', async ({ page }) => {
4+
await page.goto('/tests/skip-query');
5+
6+
// Initially, query should run and load data
7+
await expect(page.getByTestId('loading')).toBeVisible();
8+
await expect(page.getByTestId('data')).toBeVisible({ timeout: 5000 });
9+
10+
// Check the skip checkbox
11+
await page.getByTestId('skip-checkbox').check();
12+
13+
// When skipped, should show "No data" (not loading, no error, no data)
14+
await expect(page.getByTestId('no-data')).toBeVisible();
15+
16+
// Uncheck to verify it resumes
17+
await page.getByTestId('skip-checkbox').uncheck();
18+
19+
// Should load again
20+
await expect(page.getByTestId('data')).toBeVisible({ timeout: 5000 });
21+
});

src/lib/client.svelte.ts

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export const setupConvex = (url: string, options: ConvexClientOptions = {}) => {
3636
$effect(() => () => client.close());
3737
};
3838

39+
// Internal sentinel for "skip" so we don't pass the literal string through everywhere
40+
const SKIP = Symbol('convex.useQuery.skip');
41+
type Skip = typeof SKIP;
42+
3943
type UseQueryOptions<Query extends FunctionReference<'query'>> = {
4044
// Use this data and assume it is up to date (typically for SSR and hydration)
4145
initialData?: FunctionReturnType<Query>;
@@ -53,153 +57,192 @@ type UseQueryReturn<Query extends FunctionReference<'query'>> =
5357
* Subscribe to a Convex query and return a reactive query result object.
5458
* Pass reactive args object or a closure returning args to update args reactively.
5559
*
56-
* @param query - a FunctionRefernece like `api.dir1.dir2.filename.func`.
57-
* @param args - The arguments to the query function.
60+
* Supports React-style `"skip"` to avoid subscribing:
61+
* useQuery(api.users.get, () => (isAuthed ? {} : 'skip'))
62+
*
63+
* @param query - a FunctionReference like `api.dir1.dir2.filename.func`.
64+
* @param args - Arguments object / closure, or the string `"skip"` (or a closure returning it).
5865
* @param options - UseQueryOptions like `initialData` and `keepPreviousData`.
5966
* @returns an object containing data, isLoading, error, and isStale.
6067
*/
6168
export function useQuery<Query extends FunctionReference<'query'>>(
6269
query: Query,
63-
args: FunctionArgs<Query> | (() => FunctionArgs<Query>),
70+
args: FunctionArgs<Query> | 'skip' | (() => FunctionArgs<Query> | 'skip') = {},
6471
options: UseQueryOptions<Query> | (() => UseQueryOptions<Query>) = {}
6572
): UseQueryReturn<Query> {
6673
const client = useConvexClient();
6774
if (typeof query === 'string') {
6875
throw new Error('Query must be a functionReference object, not a string');
6976
}
77+
7078
const state: {
7179
result: FunctionReturnType<Query> | Error | undefined;
7280
// The last result we actually received, if this query has ever received one.
7381
lastResult: FunctionReturnType<Query> | Error | undefined;
7482
// The args (query key) of the last result that was received.
75-
argsForLastResult: FunctionArgs<Query>;
83+
argsForLastResult: FunctionArgs<Query> | Skip | undefined;
7684
// If the args have never changed, fine to use initialData if provided.
7785
haveArgsEverChanged: boolean;
7886
} = $state({
7987
result: parseOptions(options).initialData,
80-
argsForLastResult: undefined,
8188
lastResult: undefined,
89+
argsForLastResult: undefined,
8290
haveArgsEverChanged: false
8391
});
8492

8593
// When args change we need to unsubscribe to the old query and subscribe
8694
// to the new one.
8795
$effect(() => {
8896
const argsObject = parseArgs(args);
97+
98+
// If skipped, don't create any subscription
99+
if (argsObject === SKIP) {
100+
// Clear transient result to mimic React: not loading, no data
101+
state.result = undefined;
102+
state.argsForLastResult = SKIP;
103+
return;
104+
}
105+
89106
const unsubscribe = client.onUpdate(
90107
query,
91108
argsObject,
92109
(dataFromServer) => {
93110
const copy = structuredClone(dataFromServer);
94-
95111
state.result = copy;
96112
state.argsForLastResult = argsObject;
97113
state.lastResult = copy;
98114
},
99115
(e: Error) => {
100116
state.result = e;
101117
state.argsForLastResult = argsObject;
102-
// is it important to copy the error here?
103118
const copy = structuredClone(e);
104119
state.lastResult = copy;
105120
}
106121
);
122+
123+
// Cleanup on args change/unmount
107124
return unsubscribe;
108125
});
109126

110-
// Are the args (the query key) the same as the last args we received a result for?
127+
/*
128+
** staleness & args tracking **
129+
* Are the args (the query key) the same as the last args we received a result for?
130+
*/
131+
const currentArgs = $derived(parseArgs(args));
132+
const initialArgs = parseArgs(args);
133+
111134
const sameArgsAsLastResult = $derived(
112-
!!state.argsForLastResult &&
113-
JSON.stringify(convexToJson(state.argsForLastResult)) ===
114-
JSON.stringify(convexToJson(parseArgs(args)))
135+
state.argsForLastResult !== undefined &&
136+
currentArgs !== SKIP &&
137+
state.argsForLastResult !== SKIP &&
138+
jsonEqualArgs(
139+
state.argsForLastResult as Record<string, Value>,
140+
currentArgs as Record<string, Value>
141+
)
115142
);
143+
116144
const staleAllowed = $derived(!!(parseOptions(options).keepPreviousData && state.lastResult));
145+
const isSkipped = $derived(currentArgs === SKIP);
117146

118-
// Not reactive
119-
const initialArgs = parseArgs(args);
120147
// Once args change, move off of initialData.
121148
$effect(() => {
122149
if (!untrack(() => state.haveArgsEverChanged)) {
123-
if (
124-
JSON.stringify(convexToJson(parseArgs(args))) !== JSON.stringify(convexToJson(initialArgs))
125-
) {
150+
const curr = parseArgs(args);
151+
if (!argsKeyEqual(initialArgs, curr)) {
126152
state.haveArgsEverChanged = true;
127153
const opts = parseOptions(options);
128154
if (opts.initialData !== undefined) {
129-
state.argsForLastResult = $state.snapshot(initialArgs);
130-
state.lastResult = parseOptions(options).initialData;
155+
state.argsForLastResult = initialArgs === SKIP ? SKIP : $state.snapshot(initialArgs);
156+
state.lastResult = opts.initialData;
131157
}
132158
}
133159
}
134160
});
135161

136-
// Return value or undefined; never an error object.
162+
/*
163+
** compute sync result **
164+
* Return value or undefined; never an error object.
165+
*/
137166
const syncResult: FunctionReturnType<Query> | undefined = $derived.by(() => {
167+
if (isSkipped) return undefined;
168+
138169
const opts = parseOptions(options);
139170
if (opts.initialData && !state.haveArgsEverChanged) {
140171
return state.result;
141172
}
173+
142174
let value;
143175
try {
144176
value = client.disabled
145177
? undefined
146-
: client.client.localQueryResult(getFunctionName(query), parseArgs(args));
178+
: client.client.localQueryResult(
179+
getFunctionName(query),
180+
currentArgs as Record<string, Value>
181+
);
147182
} catch (e) {
148183
if (!(e instanceof Error)) {
149-
// This should not happen by the API of localQueryResult().
150184
console.error('threw non-Error instance', e);
151185
throw e;
152186
}
153187
value = e;
154188
}
155-
// If state result has updated then it's time to check the for a new local value
189+
// Touch reactive state.result so updates retrigger computations
156190
state.result;
157191
return value;
158192
});
159193

160194
const result = $derived.by(() => {
161195
return syncResult !== undefined ? syncResult : staleAllowed ? state.lastResult : undefined;
162196
});
197+
163198
const isStale = $derived(
164-
syncResult === undefined && staleAllowed && !sameArgsAsLastResult && result !== undefined
199+
!isSkipped &&
200+
syncResult === undefined &&
201+
staleAllowed &&
202+
!sameArgsAsLastResult &&
203+
result !== undefined
165204
);
205+
166206
const data = $derived.by(() => {
167-
if (result instanceof Error) {
168-
return undefined;
169-
}
207+
if (result instanceof Error) return undefined;
170208
return result;
171209
});
210+
172211
const error = $derived.by(() => {
173-
if (result instanceof Error) {
174-
return result;
175-
}
212+
if (result instanceof Error) return result;
176213
return undefined;
177214
});
178215

179-
// This TypeScript cast promises data is not undefined if error and isLoading are checked first.
216+
/*
217+
** public shape **
218+
* This TypeScript cast promises data is not undefined if error and isLoading are checked first.
219+
*/
180220
return {
181221
get data() {
182222
return data;
183223
},
184224
get isLoading() {
185-
return error === undefined && data === undefined;
225+
return isSkipped ? false : error === undefined && data === undefined;
186226
},
187227
get error() {
188228
return error;
189229
},
190230
get isStale() {
191-
return isStale;
231+
return isSkipped ? false : isStale;
192232
}
193233
} as UseQueryReturn<Query>;
194234
}
195235

196-
// args can be an object or a closure returning one
236+
/**
237+
* args can be an object, "skip", or a closure returning either
238+
**/
197239
function parseArgs(
198-
args: Record<string, Value> | (() => Record<string, Value>)
199-
): Record<string, Value> {
240+
args: Record<string, Value> | 'skip' | (() => Record<string, Value> | 'skip')
241+
): Record<string, Value> | Skip {
200242
if (typeof args === 'function') {
201243
args = args();
202244
}
245+
if (args === 'skip') return SKIP;
203246
return $state.snapshot(args);
204247
}
205248

@@ -212,3 +255,13 @@ function parseOptions<Query extends FunctionReference<'query'>>(
212255
}
213256
return $state.snapshot(options);
214257
}
258+
259+
function jsonEqualArgs(a: Record<string, Value>, b: Record<string, Value>): boolean {
260+
return JSON.stringify(convexToJson(a)) === JSON.stringify(convexToJson(b));
261+
}
262+
263+
function argsKeyEqual(a: Record<string, Value> | Skip, b: Record<string, Value> | Skip): boolean {
264+
if (a === SKIP && b === SKIP) return true;
265+
if (a === SKIP || b === SKIP) return false;
266+
return jsonEqualArgs(a, b);
267+
}

0 commit comments

Comments
 (0)