Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"devDependencies": {
"@playwright/test": "^1.54.2",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/kit": "^2.30.0",
"@sveltejs/kit": "^2.36.2",
"@sveltejs/package": "^2.4.1",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/eslint": "^9.6.1",
Expand All @@ -63,7 +63,7 @@
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"publint": "^0.2.12",
"svelte": "^5.38.1",
"svelte": "^5.38.5",
"svelte-check": "^4.3.1",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.1",
Expand Down
13 changes: 13 additions & 0 deletions src/convex/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ export const send = mutation({
}
});

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

handler: async (ctx, args) => {
if (args.fail) {
console.log('fail', args.fail);
throw new Error('test error');
}
return ctx.db.query('messages').first();
}
});

import seedMessages from './seed_messages.js';
export const seed = internalMutation({
handler: async (ctx) => {
Expand Down
8 changes: 7 additions & 1 deletion src/convex/seed_messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@ export default [
_id: '2w7d7p8ysmtr6njf4yejj0jy9hsjar0',
author: 'Arnold',
body: 'While you were talking I added a feature to the product'
}
},
{
_creationTime: 1755750014686.7732,
_id: "j572hze30nnbvzcph9836fetmh7p3zgk",
author: "Tyler Petrov",
body: "We have the new Remote Functions based API!",
}
];
220 changes: 220 additions & 0 deletions src/lib/async.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { useConvexClient } from "./client.svelte";
import { getFunctionName, type FunctionReference } from "convex/server";
import { convexToJson } from "convex/values";
import { tick } from "svelte";


export type ConvexQueryOptions<Query extends FunctionReference<'query', 'public'>> = {
// Use this data and assume it is up to date (typically for SSR and hydration)
initialData?: Query['_returnType'];
};

export function generateCacheKey<
Query extends FunctionReference<'query', 'public'>
>(
query: Query,
args: Query['_args']
) {
return getFunctionName(query)
+ JSON.stringify(convexToJson(args))
}

export class ConvexQuery<
Query extends FunctionReference<'query', 'public'>,
T = Query['_returnType'],
> {
_key: string;
#init = false;
#fn: () => Promise<T>;
#loading = $state(true);
unsubscribe: () => void;
#args: Query['_args'];

#ready = $state(false);
#raw = $state.raw<T | undefined>(undefined);
#promise: Promise<T>;

#error = $state.raw<Error | undefined>(undefined);

#then = $derived.by(() => {
const p = this.#promise;

return async (resolve?: (value: T) => void, reject?: (reason: any) => void) => {
try {
// svelte-ignore await_reactivity_loss
const value = await p;
// svelte-ignore await_reactivity_loss
await tick();
resolve?.(value as T);
} catch (error) {
reject?.(error);
}
};
});

constructor(query: Query, args: Query['_args']) {
const client = useConvexClient();

this._key = generateCacheKey(query, args);
this.#args = args;

this.#fn = () => client.query(query, this.#args);
this.#promise = $state.raw(this.#run());

this.unsubscribe = client.onUpdate(query, this.#args, (result: Query['_returnType']) => {
// The first value is resolved by the promise, so we don't need to update the query here
if (!this.#ready) return;

this.set(result);
}, (error) => {
this.#fn = () => Promise.reject(error);
this.#promise = this.#run();
});
}

#run(): Promise<T> {
// Prevent state_unsafe_mutation error on first run when the resource is created within the template
if (this.#init) {
this.#loading = true;
} else {
this.#init = true;
}

// Don't use Promise.withResolvers, it's too new still
let resolve: (value: T) => void;
let reject: (e?: any) => void;
const promise: Promise<T> = new Promise<T>((res, rej) => {
resolve = res as any;
reject = rej;
});

Promise.resolve(this.#fn())
.then((value) => {
this.#ready = true;
this.#loading = false;
this.#raw = value;
this.#error = undefined;

resolve!(value);
})
.catch((e) => {
this.#error = e;
this.#loading = false;
reject!(e);
});

return promise;
}

get then() {
return this.#then;
}

get catch() {
this.#then;
return (reject: (reason: any) => void) => {
return this.#then(undefined, reject);
};
}

get finally() {
this.#then;
return (fn: () => void) => {
return this.#then(
() => fn(),
() => fn()
);
};
}

get current(): T | undefined {
return this.#raw;
}

get data(): T | undefined {
return this.#raw;
}

get error(): Error | undefined {
return this.#error;
}

/**
* Returns true if the resource is loading or reloading.
*/
get loading(): boolean {
return this.#loading;
}

/**
* Returns true once the resource has been loaded for the first time.
*/
get ready(): boolean {
return this.#ready;
}

set(value: T): void {
this.#ready = true;
this.#loading = false;
this.#error = undefined;
this.#raw = value;
this.#promise = Promise.resolve(value);
}
}


type CacheEntry = { count: number, resource: ConvexQuery<any> };
const queryCache = new Map<string, CacheEntry>();

function removeUnusedCachedValues(cacheKey: string, entry: CacheEntry) {
void tick().then(() => {
if (!entry.count && entry === queryCache.get(cacheKey)) {
entry.resource.unsubscribe();
queryCache.delete(cacheKey);
}
});
}

export const convexQuery = <Query extends FunctionReference<'query', 'public'>>(
query: Query,
args: Query['_args']
) => {
const cacheKey = generateCacheKey(query, args);
let entry = queryCache.get(cacheKey);

let tracking = true;
try {
$effect.pre(() => {
if (entry) entry.count++;
return () => {
const entry = queryCache.get(cacheKey);
if (entry) {
entry.count--;
removeUnusedCachedValues(cacheKey, entry);
}
};
});
} catch {
tracking = false;
}

let resource = entry?.resource;
if (!resource) {
resource = new ConvexQuery(query, args);
queryCache.set(cacheKey,
(entry = {
count: tracking ? 1 : 0,
resource,
})
);
resource
.then(() => {
removeUnusedCachedValues(cacheKey, entry!);
})
.catch(() => {
queryCache.delete(cacheKey);
});
}

return resource as ConvexQuery<Query>;
};
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 * as async from './async.svelte.js';
20 changes: 17 additions & 3 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,27 @@

<div class="app">
<main>
{@render children()}
<svelte:boundary onerror={(e) => {
console.error(e);
}}>
{@render children()}

{#snippet pending()}
<div>Loading...</div>
{/snippet}
{#snippet failed(error, reset)}
<div>Error: {error}</div>
{/snippet}
</svelte:boundary>
</main>

<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
2 changes: 2 additions & 0 deletions src/routes/tests/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>Hello</p>
<a href="/tests/thenable-dev">Thenable Dev</a>
63 changes: 63 additions & 0 deletions src/routes/tests/thenable-dev/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<script lang="ts">
import { api } from '$convex/_generated/api.js';
import { convexQuery } from '$lib/async.svelte.js';

let fail = $state(false);
let skip = $state(false);

const firstMessage = $derived(await convexQuery(api.messages.firstMessage, { fail }));

const firstMessageQuery = $derived(convexQuery(api.messages.firstMessage, { fail }));
</script>

<svelte:head>
<title>Home</title>
<meta name="description" content="Svelte demo app" />
</svelte:head>

<section>
<h1>Welcome to SvelteKit with Convex</h1>
<a href="/tests">Tests</a>

<!-- <button onclick={() => fail = !fail}>{fail ? 'Fail' : 'Success'}</button> -->
<div>
<label for="fail">should fail</label>
<input id="fail" type="checkbox" bind:checked={fail} />
<label for="skip">should skip</label>
<input id="skip" type="checkbox" bind:checked={skip} />
</div>
<svelte:boundary>
<pre>Result: {JSON.stringify(firstMessage, null, 2)}</pre>

{#snippet pending()}
<div>Loading...</div>
{/snippet}

{#snippet failed(error, retry)}
<div>Error: {error}</div>
<button onclick={retry}>Retry</button>
{/snippet}
</svelte:boundary>

{#if firstMessageQuery.loading}
<div>Loading...</div>
{:else if firstMessageQuery.error}
<div>Error: {firstMessageQuery.error}</div>
{:else}
<pre>Result: {JSON.stringify(firstMessageQuery.current, null, 2)}</pre>
{/if}
</section>

<style>
section {
display: flex;
flex-direction: column;
align-items: center;
flex: 0.6;
}

h1 {
width: 100%;
text-align: center;
}
</style>
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