diff --git a/package.json b/package.json index 871244d..d6dad05 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/convex/messages.ts b/src/convex/messages.ts index 668e76f..0a615fd 100644 --- a/src/convex/messages.ts +++ b/src/convex/messages.ts @@ -22,6 +22,19 @@ export const send = mutation({ } }); +export const firstMessage = query({ + args: { + fail: v.boolean() + }, + 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) => { diff --git a/src/convex/seed_messages.ts b/src/convex/seed_messages.ts index 841a5bf..12956d4 100644 --- a/src/convex/seed_messages.ts +++ b/src/convex/seed_messages.ts @@ -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!", + } ]; diff --git a/src/lib/async.svelte.ts b/src/lib/async.svelte.ts new file mode 100644 index 0000000..111040d --- /dev/null +++ b/src/lib/async.svelte.ts @@ -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> = { + // 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; + #loading = $state(true); + unsubscribe: () => void; + #args: Query['_args']; + + #ready = $state(false); + #raw = $state.raw(undefined); + #promise: Promise; + + #error = $state.raw(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 { + // 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 = new Promise((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 }; +const queryCache = new Map(); + +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: 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; +}; diff --git a/src/lib/index.ts b/src/lib/index.ts index 31ee030..ca60263 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -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'; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f52456c..40693f3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -8,13 +8,27 @@
- {@render children()} + { + console.error(e); + }}> + {@render children()} + + {#snippet pending()} +
Loading...
+ {/snippet} + {#snippet failed(error, reset)} +
Error: {error}
+ {/snippet} +
diff --git a/src/routes/tests/+page.svelte b/src/routes/tests/+page.svelte new file mode 100644 index 0000000..7293427 --- /dev/null +++ b/src/routes/tests/+page.svelte @@ -0,0 +1,2 @@ +

Hello

+Thenable Dev \ No newline at end of file diff --git a/src/routes/tests/thenable-dev/+page.svelte b/src/routes/tests/thenable-dev/+page.svelte new file mode 100644 index 0000000..f23eba4 --- /dev/null +++ b/src/routes/tests/thenable-dev/+page.svelte @@ -0,0 +1,63 @@ + + + + Home + + + +
+

Welcome to SvelteKit with Convex

+ Tests + + +
+ + + + +
+ +
Result: {JSON.stringify(firstMessage, null, 2)}
+ + {#snippet pending()} +
Loading...
+ {/snippet} + + {#snippet failed(error, retry)} +
Error: {error}
+ + {/snippet} +
+ + {#if firstMessageQuery.loading} +
Loading...
+ {:else if firstMessageQuery.error} +
Error: {firstMessageQuery.error}
+ {:else} +
Result: {JSON.stringify(firstMessageQuery.current, null, 2)}
+ {/if} +
+ + diff --git a/src/routes/thenable/+layout.svelte b/src/routes/thenable/+layout.svelte new file mode 100644 index 0000000..f42e32a --- /dev/null +++ b/src/routes/thenable/+layout.svelte @@ -0,0 +1,18 @@ + + { + console.error(e) +}}> + {@render children()} + + {#snippet pending()} +
Loading...
+ {/snippet} + {#snippet failed(error, reset)} +

Error: {error}

+ + {/snippet} +
\ No newline at end of file diff --git a/src/routes/thenable/+page.svelte b/src/routes/thenable/+page.svelte new file mode 100644 index 0000000..f3615ea --- /dev/null +++ b/src/routes/thenable/+page.svelte @@ -0,0 +1,215 @@ + + +
+
+

Chat

+ Thenable Query +
+ +
+
+ + +
+
+ +
+
    + {#each messages as message (message._id)} +
  • + +
    +
    + {message.author} + {formatDate(message._creationTime)} +
    +
    {message.body}
    +
    +
  • + {/each} +
+
+ +
+ + + +
+
+ + diff --git a/svelte.config.js b/svelte.config.js index 2b35fe1..1eafd3f 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -11,7 +11,15 @@ const config = { // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters. - adapter: adapter() + adapter: adapter(), + alias: { + $convex: 'src/convex', + } + }, + compilerOptions: { + experimental: { + async: true + } } };