Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Is there a plan to make liveQuery more compatible with Svelte5 runes #2075

Open
newholder opened this issue Sep 23, 2024 · 16 comments
Open

Is there a plan to make liveQuery more compatible with Svelte5 runes #2075

newholder opened this issue Sep 23, 2024 · 16 comments

Comments

@newholder
Copy link

newholder commented Sep 23, 2024

With the introduction of runes, store is kinda obsolete. Is there any plan to support runes once svelte5 released?

I use it like this in svelte5

let _friends = liveQuery(async () => {
      return await db.friends
        .where("age")
        .between(18, 65)
        .toArray();
    });

let friends = $derived($_friends)
@dfahlander
Copy link
Collaborator

Thanks for bringing it up. Also asked on stackoverflow: https://stackoverflow.com/questions/78089371/dexie-livequery-with-svelte-5-runes

Need more knowledge about Svelte 5 and runes before I could do anything about it but a PR would be much welcome. Could we introduce some kind of svelte helper library that delivers runes for live queries? A bit like we have for react?

@molarmanful
Copy link

The recently-added fromStore alongside the wrapper from #1907 worked for me:

import { liveQuery } from 'dexie'
import { fromStore } from 'svelte/store'

export const liveQ = q => fromStore({
  subscribe: (run, invalidate) =>
    liveQuery(q).subscribe(run, invalidate).unsubscribe,
})

// usage

const friends = liveQ(async () => await db.friends.where('age').between(18, 65).toArray())

$inspect(friends.current)

@ericdudley
Copy link

Hello, I am also trying to use Dexie.js in a Svelte5 project with runes. I started by following the existing tutorial Get started with Dexie in Svelte tutorial. However, I got stuck when trying to make a liveQuery reactive to props/state.

I was able to get it working by combining $derived.by with a noop reference to the state I want to react to (in this case namePrefix). Is this the correct way of parameterizing a live query? Re-creating the liveQuery whenever the parameter changes?

<script lang="ts">
	import { db } from '$lib/store';
	import { liveQuery } from 'dexie';

	let { namePrefix }: { namePrefix: string } = $props();

	let friends = $derived.by(() => {
		// noop just to make it reactive
		namePrefix;

		return liveQuery(() => db.friends
				.where('name')
				.startsWithIgnoreCase(namePrefix ?? '')
				.toArray()
		);
	});
</script>

@dfahlander
Copy link
Collaborator

Hello, I am also trying to use Dexie.js in a Svelte5 project with runes. I started by following the existing tutorial Get started with Dexie in Svelte tutorial. However, I got stuck when trying to make a liveQuery reactive to props/state.

I was able to get it working by combining $derived.by with a noop reference to the state I want to react to (in this case namePrefix). Is this the correct way of parameterizing a live query? Re-creating the liveQuery whenever the parameter changes?

<script lang="ts">
	import { db } from '$lib/store';
	import { liveQuery } from 'dexie';

	let { namePrefix }: { namePrefix: string } = $props();

	let friends = $derived.by(() => {
		// noop just to make it reactive
		namePrefix;

		return liveQuery(() => db.friends
				.where('name')
				.startsWithIgnoreCase(namePrefix ?? '')
				.toArray()
		);
	});
</script>

It's correct to recreate the query whenever a closure it depends on changes. In the background the old query will be unsubscribed and a new query subscribed and the result will stay until the new query emits it's initial value.

@dfahlander
Copy link
Collaborator

dfahlander commented Oct 31, 2024

We'd need some svelte-specific library to consume live queries more slick with runes. See also my suggested helper in #2089 - it emits errors and loading status but it still need the noops for some reason (or does it). Wouldn't the svelte compiler see the closures passed to the callback? If that's the case, a plain usage of liveQuery with svelte 5 would be:

<script lang="ts">
  import { db } from '$lib/store';
  import { liveQuery } from 'dexie';

  let { namePrefix }: { namePrefix: string } = $props();

  const friends = $derived(
    liveQuery(
      () => db.friends
        .where('name')
        .startsWith(namePrefix)
        .toArray()
    )
  );
</script>

{#if $friends}
  <ul>
    {#each $friends as friend (friend.id)}
      <li>{friend.name}, {friend.age}</li>
    {/each}
  </ul>
{/if}

Could anyone confirm whether the above code would work? It would be more direct than what's suggested so far.

If errors and loading status is requested, then look at how it could be done in #2089. An updated sample using that helper would then be:

<script lang="ts">
  import { db } from '$lib/store';
  import { svelteLiveQuery } from '$lib/svelteLiveQuery'; // or where to put it...
  import { liveQuery } from 'dexie';

  let { namePrefix }: { namePrefix: string } = $props();

  const friendResult = $derived(
    svelteLiveQuery(
      () => db.friends
        .where('name')
        .startsWith(namePrefix)
        .toArray()
    )
  );
</script>

<!-- isLoading -->
{#if $friendResult.isLoading}
   <p>Loading...</p>
{/if}

<!-- error -->
{#if $friendResult.error}
   <p>Error: $friendResult.error</p>
{/if}

<!-- result -->
{#if $friendResult.value}
<ul>
  {#each $friendResult.value as friend (friend.id)}
    <li>{friend.name}, {friend.age}</li>
  {/each}
</ul>
{/if}

@dusty-phillips
Copy link
Contributor

dusty-phillips commented Nov 6, 2024

I've also been struggling with various incantations of this. It works acceptably in certain toy app scenarios, but it's a frustrating nightmare in a production app.

I think I've got a working solution now, thanks in large part to standing on the shoulders of @braebo, who posted a solution here (see the "fixed" route). The QueryRune works correctly for simple cases.

One gotcha is that it doesn't behave as expected with queries that have derived parameters.So for example, this doesn't work:

  const draftQuery = $derived(new QueryRune(liveQuery(() => getSelectedDraft(book?.id))))

(getSelectedDraft is just a promise that calls into Dexie)

This is not reactive because according to the Svelte docs:

Anything read synchronously inside the $derived expression (or $derived.by function body) is considered a dependency of the derived state.

(emphasis mine)

Because liveQuery is executing asynchronously, anything passed into it is not considered reactive. This is a slightly tidier version of the $derived.by with a no-op David suggested.

The least untidy solution I've found is to bring back the concept of ye olde deps array. 😭 I've created a function like this:

export function liveRune<T>(
  querier: () => T | Promise<T>,
  ..._dependencies: any[]
): QueryRune<T> | { current: undefined } {
  return new QueryRune(liveQuery(querier))
}

I can call it like this:

  const draftQuery = $derived(liveRune(() => getSelectedDraft(book?.id), book?.id))

The function doesn't do anything with the second book?.id but because $derived sees it as a synchronous parameter, it treats it reactively things update properly.

I've actually extended this function to short circuit if any of the deps are undefined, so cascading loads can behave sanely when waiting for data from a parent query (book, in this example):

export function liveRune<T>(
  querier: () => T | Promise<T>,
  ...dependencies: any[]
): QueryRune<T> | { current: undefined } {
  if (!dependencies.every((x) => x)) {
    return { current: undefined }
  }

  return new QueryRune(liveQuery(querier))
}

Svelte's generally done an excellent job of inferring dependencies, so I'm pretty bummed out that it isn't able to do the right thing without an explicit dependency. But it's the best I've found so far.

@braebo
Copy link

braebo commented Nov 6, 2024

@dusty-phillips thanks for sharing your findings! I'm not sure if this helps, but I've got derived working reliably here.

@dusty-phillips
Copy link
Contributor

dusty-phillips commented Jan 5, 2025

It's correct to recreate the query whenever a closure it depends on changes. In the background the old query will be unsubscribed and a new query subscribed and the result will stay until the new query emits it's initial value.

In practice, recreating the query when dependent state changes is causing the query to very briefly return undefined while the new query populates itself. Because the dependent components don't render when the data is undefined, my components momentarily flash out of existence until the defined value returned.

Has anyone else seen/resolved this? I'm experimenting with "store the old value in separate state until the next value resolves", but it makes me nervous.

Edit: Here's some code that seems to work, but continues to make me nervous:

import type { Subscriber, Unsubscriber } from 'svelte/store';

import { untrack } from 'svelte';
import { liveQuery } from 'dexie';

// With thanks to https://github.com/braebo/svelte-5-dexie-test/blob/main/src/lib/QueryRune.svelte.ts
export interface ReadableQuery<T> {
	subscribe(
		this: void,
		run: Subscriber<T>,
		invalidate?: () => void
	): {
		unsubscribe: Unsubscriber;
	};
}

export type ReadableValue<T> = T extends ReadableQuery<infer U> ? U : never;

export class QueryRune<T = ReadableValue<ReadableQuery<unknown>>> {
	current = $state<T>();

	constructor(public readonly store: ReadableQuery<T>) {
		untrack(() => {
			store
				.subscribe((v) => {
					this.current = v;
				})
				.unsubscribe();
		});

		$effect.pre(() => {
			return store.subscribe((v) => {
				this.current = v;
			}).unsubscribe;
		});
	}
}

export class DexieQuery<T, A extends any[]> {
	#querier: (...args: A) => T | Promise<T>;
	#args = $state<A>();
	#previous = $state<T>();
	#latest: undefined | QueryRune<T | undefined> = $derived(
		this.#args === undefined
			? undefined
			: new QueryRune(
					liveQuery(() => (this.#args === undefined ? undefined : this.#querier(...this.#args)))
				)
	);

	current = $derived(this.#latest?.current ?? this.#previous);

	constructor(querier: (...args: A) => T | Promise<T>) {
		this.#querier = querier;
	}

	setDependencies(...args: A) {
		this.#previous = this.#latest?.current;
		this.#args = args;
	}
}

Aside from making me nervous, I also don't like what it does to callsites:

	const allFriends = new DexieQuery(() => db.friends.toArray());
	allFriends.setDependencies();

	let ageFilter = $state(0);

	const filteredFriends = new DexieQuery((ageFilter) => {
		return db.friends.where('age').above(ageFilter).toArray();
	});
	$effect(() => filteredFriends.setDependencies(ageFilter));

I can't find a way to hide the $effect in the class.

Edit 2: Inspired by https://github.com/svecosystem/runed/blob/main/packages/runed/src/lib/utilities/previous/previous.svelte.ts I created the following:

export class CachePrevious<T> {
	#previous: T | undefined = $state(undefined);
	#curr?: T;

	constructor(getter: Getter<T>) {
		$effect(() => {
			if (this.#curr !== undefined) {
				this.#previous = this.#curr;
			}
			this.#curr = getter();
		});
	}

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

This works with the liveRune function I defined earlier:

export function liveRune<T>(querier: () => T | Promise<T>, ..._dependencies: any): QueryRune<T> {
	return new QueryRune(liveQuery(querier));
}

In use, I need three separate values:

	const filteredFriendsQuery = $derived(
		liveRune(() => {
			return db.friends.where('age').above(ageFilter).toArray();
		}, ageFilter)
	);
	const filteredFriendsPrevious = new CachePrevious(() => filteredFriendsQuery.current);
	const filteredFriends = $derived.by(
		() => filteredFriendsQuery.current ?? filteredFriendsPrevious.current
	);

I don't love it because it's still stuffing an extra copy of the stale data in RAM, but at least I've gotten rid of the $effect. I'm pretty sure there is a path to a more elegant class if only I'd gotten more sleep last night...

@dusty-phillips
Copy link
Contributor

...the problem is that I also can't sleep when my brain has a puzzle to chew on. This is working for the two toy queries I tried it with:

import { untrack } from 'svelte';
import { liveQuery } from 'dexie';

export class DexieQuery<T, A extends any[]> {
	#previous = $state<T>();
	#store = $state<T>();

	current = $derived(this.#store ?? this.#previous);

	constructor(querier: (...args: A) => T | Promise<T>, dependencies: () => A) {
		$effect(() => {
			const deps = dependencies();

			untrack(() => {
				if (this.#store !== undefined) {
					this.#previous = this.#store;
				}
				const store = liveQuery(() => querier(...deps));
				return store.subscribe((value) => {
					this.#store = value;
					this.#previous = undefined;
				}).unsubscribe;
			});
		});
	}
}

It's basically a bit of all the previous proposed solutions mixed together. In use it looks like this:

	const allFriendsQuery = new DexieQuery(
		() => db.friends.toArray(),
		() => [] as const
	);
	const allFriends = $derived(allFriendsQuery.current);

	const filteredFriendsQuery = new DexieQuery(
		(age) => db.friends.where('age').above(age).toArray(),
		() => [ageFilter] as const
	);
	const filteredFriends = $derived(filteredFriendsQuery.current);

	$inspect('filteredFriends', filteredFriends); // never undefined once set!

The as const is kind of annoying but otherwise, I think it might work.

@oliverdowling
Copy link

Is it required to pass the dependencies to the query? This seems to work for me in basic test:

export class DexieQuery<T, A extends unknown[]> {
	#previous = $state<T>();
	#store = $state<T>();

	current = $derived(this.#store ?? this.#previous);

	constructor(querier: () => T | Promise<T>, dependencies?: () => A) {
		$effect(() => {
			dependencies?.();

			untrack(() => {
				if (this.#store !== undefined) {
					this.#previous = this.#store;
				}
				const store = liveQuery(() => querier());
				return store.subscribe((value) => {
					this.#store = value;
					this.#previous = undefined;
				}).unsubscribe;
			});
		});
	}
}
// Usage:
const allFriendsQuery = new DexieQuery(async () => await db.friends.toArray());
const filteredFriendsQuery = new DexieQuery(
	async () => await db.friends.where('age').above(age).toArray(),
	() => [age]
);

In my use-case, I dont ever change back to undefined so I have been using something a bit simpler:

export function stateQuery<T>(querier: () => T | Promise<T>, dependencies?: () => unknown[]) {
	const query = $state<{ result?: T }>({});
	$effect(() => {
		dependencies?.();
		return liveQuery(querier).subscribe((result) => {
			if (result !== undefined) {
				query.result = result;
			}
		}).unsubscribe;
	});
	return { query };
}
// Usage:
let { query: friendsQuery } = stateQuery(
	async () => {
		return await db.friends.where('age').above(age).toArray();
	},
	() => [age]
);

I also dont bother to use another $derived and just reference friendsQuery.result

@dusty-phillips
Copy link
Contributor

dusty-phillips commented Jan 6, 2025

Excellent! To make it even simpler we can return the query directly and not bother with destructuring:

export function stateQuery<T>(
	querier: () => T | Promise<T>,
	dependencies: () => unknown[]
): { current?: T } {
	const query = $state<{ current?: T }>({ current: undefined });
	$effect(() => {
		dependencies?.();
		return liveQuery(querier).subscribe((result) => {
			if (result !== undefined) {
				query.current = result;
			}
		}).unsubscribe;
	});
	return query;
}
	let filteredFriendsQuery = stateQuery(
		() => db.friends.where('age').above(ageFilter).toArray(),
		() => [ageFilter]
	);
	const filteredFriends = $derived(filteredFriendsQuery.current);
	$inspect('filteredFriends', filteredFriends); // never undefined once set!

@dfahlander
Copy link
Collaborator

Seems you have found something useful here ! Not sure I grip everything yet, but would it be doable to put this into a library? I would love to update the svelte docs when we have something that seems to be a good solution for svelte runes.

@dusty-phillips
Copy link
Contributor

I can try but no promises on timing as I’m pretty swamped! Would you like a PR for a new dexie.js package a la react-live-query, or a third party solution?

@dfahlander
Copy link
Collaborator

I'm open to whatever would feel comfortable for you - if you'd prefer owning it I'm happy. I you rathe want to hand it over, a recursive copy of libs/dexie-react-hooks would be a good start - remove its react deps, code and tests and put in the deps and code needed for svelte. That would be a nice start as a PR.

@oliverdowling
Copy link

I had some time today so I've made a pull request, let me know if there are any issues you might like me to address.

@dlintw
Copy link

dlintw commented Jan 19, 2025

I tried to use the workaround solution, please advise me better solution. source code in.

https://github.com/dlintw/svelte5-ts-tailwind-go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: In progress
Development

No branches or pull requests

8 participants