Skip to content

Conversation

@Tyler-Petrov
Copy link
Contributor

This PR adds a handler for calling (and subscribing to) a convex query using Svelte's new await keyword. I made no attempt to share logic with the old useQuery handler and my new implementation. Everything I've added is in a new thenable-query.svelte.ts. I also added a new example page under /thenable to show the new handler.

I also upgraded all the dev deps, and added runed and esm-env (which should have been there previously) as standard deps.

I'm new to this whole PR thing, so I welcome feedback on the PR itself and my presentation.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@vercel
Copy link

vercel bot commented Aug 14, 2025

@Tyler-Petrov is attempting to deploy a commit to the Convex Team on Vercel.

A member of the Team first needs to authorize it.

@Tyler-Petrov
Copy link
Contributor Author

Tyler-Petrov commented Aug 14, 2025

One thing I noticed is HMR doesn't re-render a component if the new handler is present in it. I'm not sure why this is as there isn't an error on the svelte boundary.

I also wish there was a way to return the query result directly and not use a getter. This would be more inline with how Remote Functions work, but I don't see a way to accomplish that do to how Svelte's reactivity model works.

@thomasballinger thomasballinger mentioned this pull request Aug 18, 2025
@thomasballinger
Copy link
Collaborator

Thanks for the contribution @Tyler-Petrov.

Thanks for updating deps, I just landed that as #33 just to keep these PRs smaller since we squash the commits when we merge PRs.

Copy link
Collaborator

@thomasballinger thomasballinger left a comment

Choose a reason for hiding this comment

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

Is there a better name for this? Between useQuery and convexQuery it's not clear to me which returns a promise.

I'd love docs on when to use which of these, I don't understand Svelte await yet and I dont' know

I'd rather not duplicate so much code here, it looks like the meat of this PR is this code below. Could we rename useQuery to useQueryInner, and make two wrappers for it, useQuery and useQueryAsync or useQueryAwaitable or something?

	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;

}
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

// If the args have never changed, fine to use initialData if provided.
haveArgsEverChanged: boolean;
} = $state({
result: extractSnapshot(options).initialData,
Copy link
Collaborator

Choose a reason for hiding this comment

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

some changes like this and MaybeGetter look like changes we should make to src/lib/client.svelte.ts too, could be a separate PR but would be nice to fix these as well

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed. I'll defer to you on if you want a separate PR. I'm happy to actually put it together if that's what we decide

@thomasballinger
Copy link
Collaborator

Thanks for the contribution.

As an experimental thing, happy to merge this with all the duplicated code and duplicated example code so folks can try it out; the only thing stopping me is a description of why someone might use this instead and a better name, because convexQuery vs useConvexQuery is pretty confusing to me. I'd love to get the other improvements you made to the code into the orginal impl too.

To get this in beyond an experimental thing I'd like to not duplicate so much code. I also want to understand what this is good for, why this is a helpful new API. If it's better, we can tell everyone to use it instead! I just don't understand what's nice about await yet.

@Tyler-Petrov
Copy link
Contributor Author

Tyler-Petrov commented Aug 18, 2025

Thanks for updating deps, I just landed that as #33 just to keep these PRs smaller since we squash the commits when we merge PRs.

Makes sense. I'll keep that in mind.

* @param options - UseQueryOptions like `initialData` and `keepPreviousData`.
* @returns an object containing data, isLoading, error, and isStale.
*/
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

* @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 😅

@thomasballinger
Copy link
Collaborator

Got it, now that I understand this is a proposal to (eventually) replace the existing useQuery function the code duplication makes more sense. So that users can try this out out without making it unclear let's name it something different, and so that we can maintain these two implementations side by side for at least a few months let's duplicate less code. I think this promise-based approach could be a wrapper around the existing useQuery so it was just a few dozen lines of code, but fine to keep the duplication if this has a name like import { convexQuery } from "convex-svelte/awaitable" or import { useQueryPromise } from "convex-svelte" and we can refactor later.

@Tyler-Petrov
Copy link
Contributor Author

the only thing stopping me is a description of why someone might use this instead

There are a few things that add up to a big DX win.

  • One loading widget for the whole page. This avoids layout shift from content resolving separately. the loading widget is set via a <svelte:boundary>, so await calls can be scoped at a component tree level
  • It's more JS idiomatic call await then check a property
  • Dependencies are tracked by default and run in parallel when possible
  • Although not out yet, but the svelte renderer will pre-fetch promises before page load

@thomasballinger
Copy link
Collaborator

Oh cool! Sounds like what I'm familiar with in React as "suspense." In this case I don't think we'll want to get rid of useQuery, which works better for when you don't want to block the render, but we definitely want this!

One possible API is const data = useQuery(api.foo.bar, args, { await: true }), although hopefully we can go all the way and make it the default, and have const data = useQuery(api.foo.bar, args, { noAwait: true }) or something to opt out. If convexQuery is the more Svelte-idiomatic name to use happy to change that, but I don't want to use a new name just because there's a new behavior if the name has nothing to do with the behavior change.

Happen to know what any other data fetching libraries are doing for this? I don't see a mention of this in TanStack Query for Svelte.

@Tyler-Petrov
Copy link
Contributor Author

Oh cool! Sounds like what I'm familiar with in React as "suspense." In this case I don't think we'll want to get rid of useQuery, which works better for when you don't want to block the render, but we definitely want this!

Yeah, it's an exciting day to be a Svelte dev! 😁

Happen to know what any other data fetching libraries are doing for this? I don't see a mention of this in TanStack Query for Svelte.

The closest thing I know of right now is Svelte's Remote Functions. It uses a query function to fetch data from the server via a tRPC like api. I think the useQuery name was a by product of the React hook. The useNoun isn't really a Svelte thing.

It is possible to have queries that don't block render by wrapping the await in an additional <svelte:boundary>. Here's my Playground Example. In the example the 5 second promise doesn't block the 1 and 2 second promises from resolving the UI because of the additional boundary. The 2 second promise does block the 1 second promise because they're both children of the same boundary.

I don't personally love the idea of const data = useQuery(api.foo.bar, args, { await: true }). It feels like the one api is trying to do too much. My vote is if we decide to keep both implementations then have two functions that have shared logic.

export const firstMessage = query({
args: {
fail: v.boolean()
},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is in for dev purposes for now, and won't be in the final PR

const unsubscribe = client.onUpdate(
queryFunc,
args,
(result) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This onUpdate call makes convexQuery recursive when called from inside the markup. After setting state.current the whole function gets rebuilt, so onUpdate runs again. The function still works. I'd be curious if anyone has any solutions to this problem. I looked into getting the underlying query token from the convex client, but I gave up trying to find methods that weren't private to do what I wanted.

/* If the query is already in the cache, return the cached value */
client.query(queryFunc, args).then((result) => {
resolve(value ?? result);
}).catch((err) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This call doesn't trigger another call to the server, as there's a local cache for onUpdate (which query calls under the hood)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For dev on this feature. Won't be included in final PR

>(
queryFunc: Query,
args: Args,
options: ConvexQueryOptions<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 only option is initialData now. Should initialData be a top level parameter

Copy link
Collaborator

Choose a reason for hiding this comment

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

No let's leave it as options in case something else comes up, we're always considering all kinds of options like how long to stay subscribed after the subscription is no longer needed, whether to wait for auth, etc

@PaperPrototype
Copy link

PaperPrototype commented Aug 21, 2025

I don't personally love the idea of const data = useQuery(api.foo.bar, args, { await: true }). It feels like the one api is trying to do too much. My vote is if we decide to keep both implementations then have two functions that have shared logic.

Having two separate functions seems a bit finicky and weird since they literally do the exact same thing other than async, and once asynchronous svelte comes out I think it would make sense for this to be the default (maybe even potentially completely removing the non async version).

As a compromise maybe mark the current version of convex-svelte as a svelte 5 specific version (and have 2 separate functions), but once svelte 6 is fully released remove the non async version?

@PaperPrototype
Copy link

PaperPrototype commented Aug 21, 2025

There is also the potential that the overall strategy for convex-svelte could change with svelte 6 if it somehow becomes possible to somehow declare query's in a remote function (but I don't know enough to warrant if that is a plausible guess or not on my part).

EDIT actually now that I'm thinking about this more carefully the only benefit I can see with this would be bypassing the need to have an initialData parameter and a load function to populate it. Simplifying SSR and only needing to have code in one place... still I'm unsure if this is possible.

@PaperPrototype
Copy link

PaperPrototype commented Aug 21, 2025

To take it further. You can declare a remote function like this:

import { query } from '$app/server';
import * as db from '$lib/server/database';

export const getPosts = query(async () => {
	const posts = await db.sql`
		SELECT title, slug
		FROM post
		ORDER BY published_at
		DESC
	`;

	return posts;
});

And then use it in your page however you see fit (either in the script or in your markdown, and you don't need to hold it in a variable since it is automatically cached ).

<script lang="ts">
	import { getPosts } from './data.remote';
	
	// // You can also do it this way
	// let posts = getPosts();
</script>

<h1>Recent posts</h1>

<ul>
	{#each await getPosts() as { title, slug }}
		<li><a href="/blog/{slug}">{title}</a></li>
	{/each}
	<!---{#each await posts as { title, slug }}
		<li><a href="/blog/{slug}">{title}</a></li>
	{/each}-->
    <button onclick={() => getPosts().refresh()}>
        Check for new posts
    </button>
</ul>

EDIT: wouldn't want to use refresh() since it calls the remote function from the server (realizing this after Tyler pointed it out)
Subsequent calls to getPosts() won't necessarily trigger a fetch. From the docs:

Any query can be updated via its refresh method:

<button onclick={() => getPosts().refresh()}>
Check for new posts

Queries are cached while they’re on the page, meaning getPosts() === getPosts(). This means you don’t need a reference like const posts = getPosts() in order to refresh the query.

If convex-svelte could somehow take advantage of this I think it would be excellent. Although this is perhaps now getting pretty far off topic 😅

@Tyler-Petrov
Copy link
Contributor Author

Tyler-Petrov commented Aug 21, 2025

The end game is to remove the old useQuery handler in favor of the new convexQuery, but that's down the road. Backwards compatibility is something we need to keep in mind.

I don't see how Remote Functions are helpful here. They're designed to facilitate communication from the backend to the frontend via a developer friendly api, but Convex functions are on their own server (aka not in SvelteKit). This means that best case SvelteKit is listening to updates from Convex and then streaming the updates to the client. It's round about, and I don't see any advantages. If you're worried about missing out on the DX of Remote Functions have no fear, because the new handler has a very similar API. If you'd like you can await the query right in the markup like so: <p>{await convexQuery(api.foo.bar, {})}</p>

Separate calls of convexQuery are not equal, but they evaluate to the same thing. The Convex client has an underling cache that all queries access. This means awaiting the same query twice doesn't ping the Convex backend twice.

@PaperPrototype
Copy link

PaperPrototype commented Aug 21, 2025

You would use the remote function PURELY to load the initialData and nothing else (thus SSR).

There is also the potential that the overall strategy for convex-svelte could change with svelte 6 if it somehow becomes possible to somehow declare [convex] query's in a remote function.

I'm now realizing how silly this ^ comment was on my part since you have to define your backend queries in the convex/ folder (and I doubt that will change anytime soon). The only way you could "declare [convex] query's in a remote function" would be to import it, use it to get initialData, and then re-export it from your remote function and then import everything from the remote function on the frontend page with the initialData... But yeah, my hope being that eventually you could get a one-liner API working that does all that (minus the import and re-export) and avoid the need for a +page.server.ts load function while still getting SSR and all the benefits of convex.

@Tyler-Petrov
Copy link
Contributor Author

Hindsight is 20/20 😂

So actually what we have now will work SSR once Svelte has an async renderer. Remote Functions is just a fancy wrapper around fetch, so the good news is any promise/thenable will benefit from the new async Svelte renderer, not just Remote Functions! Right now the Svelte renderer is synchronous.

Here's a video of a talk by Rich Harris called Promise.then() where he lays out how Remote Functions and Async Svelte work. Would highly recommend! It got me up to speed on everything.

@Tyler-Petrov
Copy link
Contributor Author

I have yet again rewrote the handler. This time (thanks to augment code) I copied the client API that Svelte uses internally for Remote Functions, and customized it to our needs. I also made a cache so that calls to convexQuery only create an instance of ConvexQuery when there isn't an object already cached. I'm much happier with this version, and it's at a point where I'd be fine with merging it, so others can get a feel for it, and offer their feedback.

@PaperPrototype
Copy link

PaperPrototype commented Oct 10, 2025

No way, did you get it working with RemoteFunctions?... or am I misunderstanding something again XD

@Tyler-Petrov
Copy link
Contributor Author

Yes and no (mostly no 🙃). It uses a customized copy of the implementation for the client Remote Functions API. Yesterday I just pushed out an update that fixes an async reactivity loss error, so everything should be mostly usable. There are a few other minor bugs still. If you clone the repo there's an example page for the new async handler, so that's a good place to start messing around with it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants