Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8a1f7e8
Ignore pnpm lock file
Tyler-Petrov Aug 14, 2025
5431649
Upgraded installed packages
Tyler-Petrov Aug 14, 2025
9a4f5e7
Add the query handler
Tyler-Petrov Aug 14, 2025
cf22c15
Add the example
Tyler-Petrov Aug 14, 2025
42f4e04
Add error handling to the svelte:boundary
Tyler-Petrov Aug 14, 2025
35ac057
Add the query handler
Tyler-Petrov Aug 14, 2025
ba219cb
Add the example
Tyler-Petrov Aug 14, 2025
c39f26b
Add error handling to the svelte:boundary
Tyler-Petrov Aug 14, 2025
e198b77
convert spaces to tabs
thomasballinger Aug 18, 2025
5cd205b
Merge remote-tracking branch 'refs/remotes/origin/thenable-query' int…
Tyler-Petrov Aug 18, 2025
e308b8d
Updated the convexQuery api to resemble Sveltekits Remote Function api
Tyler-Petrov Aug 21, 2025
b76ea52
Renamed module exports
Tyler-Petrov Aug 21, 2025
7380e7d
Updated the return type for convexQuery
Tyler-Petrov Aug 21, 2025
ee0abfd
Updated types. Added the initialData option
Tyler-Petrov Aug 21, 2025
6234286
Added skip to query options to prevent the query from updating
Tyler-Petrov Aug 21, 2025
3650b0a
Rewrite to convexQuery handler with a customized implementation of th…
Tyler-Petrov Aug 26, 2025
daf0091
fixed type inferance and imports
Tyler-Petrov Aug 26, 2025
dfc39ae
Added initialData as an option
Tyler-Petrov Aug 26, 2025
c000d2e
Removed options
Tyler-Petrov Aug 26, 2025
7cdedbb
spaces to tabs
Tyler-Petrov Aug 26, 2025
7ea226f
Solved async reactivity loss error by using the value from the promise
Tyler-Petrov Oct 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Reexport your entry components here

export { useConvexClient, setupConvex, useQuery, setConvexClientContext } from './client.svelte.js';
export { convexQuery } from './thenable-query.svelte.js';
187 changes: 187 additions & 0 deletions src/lib/thenable-query.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { untrack } from 'svelte';
import {
type FunctionReference,
type FunctionArgs,
type FunctionReturnType,
getFunctionName
} from 'convex/server';
import { convexToJson, } from 'convex/values';
import { extract, type MaybeGetter } from 'runed';
import { useConvexClient } from './index.js';

export type UseQueryOptions<Query extends FunctionReference<'query'>> = {
// Use this data and assume it is up to date (typically for SSR and hydration)
initialData?: FunctionReturnType<Query>;
// Instead of loading, render result from outdated args
keepPreviousData?: boolean;
};

// Note that swapping out the current Convex client is not supported.
/**
* Subscribe to a Convex query and return a reactive query result object.
* Pass reactive args object or a closure returning args to update args reactively.
*
* @param query - a FunctionRefernece like `api.dir1.dir2.filename.func`.
* @param args - The arguments to the query function.
* @param options - UseQueryOptions like `initialData` and `keepPreviousData`.
* @returns an object containing data, isLoading, error, and isStale.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note this is no longer true, please update this docstring and remove as much code as possible from this implementation if we're duplicating code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right 😅

*/
export function convexQuery<Query extends FunctionReference<'query'>>(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name for the new function totally depends on if it will be the "condoned" handler to use. await is under an experimental flag until Svelte 6 is released, so naming it query might not be the best idea until then. I don't love the idea of having it named anything else after Svelte 6 though. For example having to call useAsyncQuery seems verbose if it will be the most idiomatic solution in a few months time.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's say it's not the replacement API until Svelte 6 is released or until I understand why it's better. Until then call it something else or import it from a different entry point, like import { convexQuery } from convex-svelte/awaitable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that. I opted for import { convexQuery } from convex-svelte/async instead, but same idea

query: Query,
args: MaybeGetter<FunctionArgs<Query>>,
options: MaybeGetter<UseQueryOptions<Query>> = {}
) {
const client = useConvexClient();
if (typeof query === 'string') {
throw new Error('Query must be a functionReference object, not a string');
}
const state: {
result: FunctionReturnType<Query> | Error | undefined;
// The last result we actually received, if this query has ever received one.
lastResult: FunctionReturnType<Query> | Error | undefined;
// The args (query key) of the last result that was received.
argsForLastResult: FunctionArgs<Query>;
// If the args have never changed, fine to use initialData if provided.
haveArgsEverChanged: boolean;
} = $state({
result: extractSnapshot(options).initialData,
argsForLastResult: undefined,
lastResult: undefined,
haveArgsEverChanged: false
});

// When args change we need to unsubscribe to the old query and subscribe
// to the new one.
$effect(() => {
const argsObject = extractSnapshot(args);
const unsubscribe = client.onUpdate(
query,
argsObject,
(dataFromServer) => {
const copy = structuredClone(dataFromServer);

state.result = copy;
state.argsForLastResult = argsObject;
state.lastResult = copy;
},
(e: Error) => {
state.result = e;
state.argsForLastResult = argsObject;
// is it important to copy the error here?
const copy = structuredClone(e);
state.lastResult = copy;
}
);
return unsubscribe;
});

// Are the args (the query key) the same as the last args we received a result for?
const sameArgsAsLastResult = $derived(
!!state.argsForLastResult &&
JSON.stringify(convexToJson(state.argsForLastResult)) ===
JSON.stringify(convexToJson(extractSnapshot(args)))
);
const staleAllowed = $derived(!!(extractSnapshot(options).keepPreviousData && state.lastResult));

// Not reactive
const initialArgs = extractSnapshot(args);
// Once args change, move off of initialData.
$effect(() => {
if (!untrack(() => state.haveArgsEverChanged)) {
if (
JSON.stringify(convexToJson(extractSnapshot(args))) !== JSON.stringify(convexToJson(initialArgs))
) {
state.haveArgsEverChanged = true;
const opts = extractSnapshot(options);
if (opts.initialData !== undefined) {
state.argsForLastResult = $state.snapshot(initialArgs);
state.lastResult = extractSnapshot(options).initialData;
}
}
}
});

// Return value or undefined; never an error object.
const syncResult: FunctionReturnType<Query> | undefined = $derived.by(() => {
const opts = extractSnapshot(options);
if (opts.initialData && !state.haveArgsEverChanged) {
return state.result;
}
let value;
try {
value = client.disabled
? undefined
: client.client.localQueryResult(getFunctionName(query), extractSnapshot(args));
} catch (e) {
if (!(e instanceof Error)) {
// This should not happen by the API of localQueryResult().
console.error('threw non-Error instance', e);
throw e;
}
value = e;
}
// If state result has updated then it's time to check the for a new local value
state.result;
return value;
});

const result = $derived.by(() => {
if (syncResult !== undefined) {
return syncResult;
}
if (staleAllowed) {
return state.lastResult;
}
return undefined;
});
const isStale = $derived(
syncResult === undefined && staleAllowed && !sameArgsAsLastResult && result !== undefined
);
const data = $derived.by(() => {
if (result instanceof Error) {
return undefined;
}
return result;
});
const error = $derived.by(() => {
if (result instanceof Error) {
return result;
}
return undefined;
});
const isLoading = $derived(error === undefined && data === undefined);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks unused, along with isStale above. Is there something special going on here that makes this used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isLoading and isStale properties were returned as a part of the query result in the useQuery implementation. I didn't remove them so that we could have a discussion on if they needed to be apart of the new handler. I should have originally added a little section about that in the PR description

isLoading:
As for me I don't see this as a helpful property anymore because the promise itself will say if it has/hasn't resolved

isStale:
TBH I didn't really get the use of this property. My guess is if the client looses connection with the server, but still has old data, but I don't know

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, I agree isLoading should be removed. isStale is true when you change the arguments to a query but the old results are still displayed because the new results haven't loaded, see the "Display old results while loading" checkbox in https://convex-svelte.vercel.app/ for an example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this comment. That makes sense. My most recent implementation doesn't handle args changing internally for better or worse


type PromiseResult = { get data(): NonNullable<FunctionReturnType<Query>> };
let resolveProxyPromise: (value: PromiseResult) => void;
let rejectProxyPromise: (value: Error) => void;
const proxyPromise = new Promise<PromiseResult>((resolve, reject) => {
resolveProxyPromise = resolve;
rejectProxyPromise = reject;
});

$effect(() => {
if (error) {
rejectProxyPromise(error);
} else if (data) {
resolveProxyPromise({
get data() {
return data
}
});
}
});

// This TypeScript cast promises data is not undefined if error and isLoading are checked first.
return {
then: (
onfulfilled: (value: PromiseResult) => void,
onrejected: (reason: any) => void
) => {
proxyPromise.then(onfulfilled, onrejected);
},
} as const;
}

function extractSnapshot<T>(value: MaybeGetter<T>) {
return $state.snapshot(extract(value));
}
7 changes: 5 additions & 2 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@

<footer>
<p>
visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to learn SvelteKit and
<a href="https://docs.convex.dev">docs.convex.dev</a> to learn Convex
The project uses
<a href="https://github.com/get-convex/convex-svelte">convex-svelte</a>,
<a href="https://docs.convex.dev">Convex</a>,
and
<a href="https://kit.svelte.dev">SvelteKit</a>
</p>
</footer>
</div>
Expand Down
18 changes: 18 additions & 0 deletions src/routes/thenable/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script lang="ts">
import type { LayoutProps } from './$types';

let { data, children }: LayoutProps = $props();
</script>
<svelte:boundary onerror={(e) => {
console.error(e)
}}>
{@render children()}

{#snippet pending()}
<div>Loading...</div>
{/snippet}
{#snippet failed(error, reset)}
<p>Error: {error}</p>
<button onclick={reset}>oops! try again</button>
{/snippet}
</svelte:boundary>
Loading