diff --git a/.changeset/pink-pots-jam.md b/.changeset/pink-pots-jam.md new file mode 100644 index 0000000000..3556317bce --- /dev/null +++ b/.changeset/pink-pots-jam.md @@ -0,0 +1,7 @@ +--- +'@tanstack/svelte-query-persist-client': major +'@tanstack/svelte-query-devtools': major +'@tanstack/svelte-query': major +--- + +BREAKING: Migrate to svelte runes (signals). Requires [Svelte v5.25.0](https://github.com/sveltejs/svelte/releases/tag/svelte%405.25.0) or newer. Please see the [migration guide](https://tanstack.com/query/latest/docs/framework/svelte/migrate-from-v5-to-v6). diff --git a/docs/config.json b/docs/config.json index f5324984ee..0ca2b1fbac 100644 --- a/docs/config.json +++ b/docs/config.json @@ -129,8 +129,8 @@ "to": "framework/svelte/ssr" }, { - "label": "Reactivity", - "to": "framework/svelte/reactivity" + "label": "Migrate from v5 to v6", + "to": "framework/svelte/migrate-from-v5-to-v6" } ] }, diff --git a/docs/framework/svelte/installation.md b/docs/framework/svelte/installation.md index e4ad607ee0..7fd45eb7df 100644 --- a/docs/framework/svelte/installation.md +++ b/docs/framework/svelte/installation.md @@ -5,8 +5,6 @@ title: Installation You can install Svelte Query via [NPM](https://npmjs.com). -> v5 is currently available as a release-candidate. We don't anticipate any major API changes from here on out. We encourage you to try it out and report any issues you find. - ### NPM ```bash diff --git a/docs/framework/svelte/migrate-from-v5-to-v6.md b/docs/framework/svelte/migrate-from-v5-to-v6.md new file mode 100644 index 0000000000..8b52b9a758 --- /dev/null +++ b/docs/framework/svelte/migrate-from-v5-to-v6.md @@ -0,0 +1,77 @@ +## Overview + +While Svelte v5 has legacy compatibility with the stores syntax from Svelte v3/v4, it has been somewhat buggy and unreliable for this adapter. The `@tanstack/svelte-query` v6 adapter fully migrates to the runes syntax, which relies on signals. This rewrite should also simplify the code required to ensure your query inputs remain reactive. + +## Installation + +Please ensure your project has [Svelte v5.25.0](https://github.com/sveltejs/svelte/releases/tag/svelte%405.25.0) or newer. + +Run `pnpm add @tanstack/svelte-query@latest` (or your package manager's equivalent). + +> Note that `@tanstack/svelte-query` v6 depends on `@tanstack/query-core` v5. + +## Thunks + +Like the Solid adapter, most functions for the Svelte adapter now require options to be provided as a "thunk" (`() => options`) to provide reactivity. + +```diff +-const query = createQuery({ ++const query = createQuery(() => ({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), +-}) ++})) +``` + +## Accessing Properties + +Given the adapter no longer uses stores, it is no longer necessary to prefix with `$`. + +```diff +-{#if $todos.isSuccess} ++{#if todos.isSuccess} + +{/if} +``` + +## Reactivity + +You previously needed to do some funky things with stores to achieve reactivity for inputs. This is no longer the case! You don't even need to wrap your query in a `$derived`. + +```diff +-const intervalMs = writable(1000) ++let intervalMs = $state(1000) + +-const query = createQuery(derived(intervalMs, ($intervalMs) => ({ ++const query = createQuery(() => ({ + queryKey: ['refetch'], + queryFn: async () => await fetch('/api/data').then((r) => r.json()), + refetchInterval: $intervalMs, +-}))) ++})) +``` + +## Disabling Legacy Mode + +If your component has any stores, it might not properly switch to runes mode. You can ensure your application is using runes in two ways: + +### On a per-file basis + +In each `.svelte` file, once you have migrated to runes, add ``. This is better for large applications requiring gradual migration. + +### On an project-wide basis + +In your `svelte.config.js`, add the following to config: + +```json + compilerOptions: { + runes: true, + }, +``` + +This can be added once you've 100% eradicated stores syntax from your app. diff --git a/docs/framework/svelte/overview.md b/docs/framework/svelte/overview.md index f1122355aa..28923e9d63 100644 --- a/docs/framework/svelte/overview.md +++ b/docs/framework/svelte/overview.md @@ -5,6 +5,8 @@ title: Overview The `@tanstack/svelte-query` package offers a 1st-class API for using TanStack Query via Svelte. +> Migrating from stores to the runes syntax? See the [migration guide](../migrate-from-v5-to-v6). + ## Example Include the QueryClientProvider near the root of your project: @@ -28,19 +30,19 @@ Then call any function (e.g. createQuery) from any component:
- {#if $query.isLoading} + {#if query.isLoading}

Loading...

- {:else if $query.isError} -

Error: {$query.error.message}

- {:else if $query.isSuccess} - {#each $query.data as todo} + {:else if query.isError} +

Error: {query.error.message}

+ {:else if query.isSuccess} + {#each query.data as todo}

{todo.title}

{/each} {/if} @@ -62,6 +64,8 @@ Svelte Query offers useful functions and components that will make managing serv - `useQueryClient` - `useIsFetching` - `useIsMutating` +- `useMutationState` +- `useIsRestoring` - `useHydrate` - `` - `` @@ -70,5 +74,4 @@ Svelte Query offers useful functions and components that will make managing serv Svelte Query offers an API similar to React Query, but there are some key differences to be mindful of. -- Many of the functions in Svelte Query return a Svelte store. To access values on these stores reactively, you need to prefix the store with a `$`. You can learn more about Svelte stores [here](https://learn.svelte.dev/tutorial/writable-stores). -- If your query or mutation depends on variables, you must use a store for the options. You can read more about this [here](../reactivity). +- The arguments to the `create*` functions must be wrapped in a function to preserve reactivity. diff --git a/docs/framework/svelte/reactivity.md b/docs/framework/svelte/reactivity.md deleted file mode 100644 index 463161d304..0000000000 --- a/docs/framework/svelte/reactivity.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -id: reactivity -title: Reactivity ---- - -Svelte uses a compiler to build your code which optimizes rendering. By default, components run once, unless they are referenced in your markup. To be able to react to changes in options you need to use [stores](https://svelte.dev/docs/svelte-store). - -In the below example, the `refetchInterval` option is set from the variable `intervalMs`, which is bound to the input field. However, as the query is not able to react to changes in `intervalMs`, `refetchInterval` will not change when the input value changes. - -```svelte - - - -``` - -To solve this, we can convert `intervalMs` into a writable store. The query options can then be turned into a derived store, which will be passed into the function with true reactivity. - -```svelte - - - -``` diff --git a/docs/framework/svelte/ssr.md b/docs/framework/svelte/ssr.md index ac6d5ee7ae..7448229caa 100644 --- a/docs/framework/svelte/ssr.md +++ b/docs/framework/svelte/ssr.md @@ -58,11 +58,11 @@ export async function load() { export let data: PageData - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['posts'], queryFn: getPosts, initialData: data.posts, - }) + })) ``` @@ -136,10 +136,10 @@ export async function load({ parent, fetch }) { import { createQuery } from '@tanstack/svelte-query' // This data is cached by prefetchQuery in +page.ts so no fetch actually happens here - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['posts'], queryFn: async () => (await fetch('/api/posts')).json(), - }) + })) ``` diff --git a/eslint.config.js b/eslint.config.js index 03252921f0..248fb6bc2d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -44,6 +44,7 @@ export default [ '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-unsafe-function-type': 'off', 'no-case-declarations': 'off', + 'prefer-const': 'off', }, }, { diff --git a/examples/svelte/auto-refetching/src/routes/+layout.svelte b/examples/svelte/auto-refetching/src/routes/+layout.svelte index ef60a2f559..4b170dc01e 100644 --- a/examples/svelte/auto-refetching/src/routes/+layout.svelte +++ b/examples/svelte/auto-refetching/src/routes/+layout.svelte @@ -4,6 +4,8 @@ import { QueryClientProvider, QueryClient } from '@tanstack/svelte-query' import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools' + const { children } = $props() + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -15,7 +17,7 @@
- + {@render children()}
diff --git a/examples/svelte/auto-refetching/src/routes/+page.svelte b/examples/svelte/auto-refetching/src/routes/+page.svelte index 8873b4d922..508fd3b04f 100644 --- a/examples/svelte/auto-refetching/src/routes/+page.svelte +++ b/examples/svelte/auto-refetching/src/routes/+page.svelte @@ -5,32 +5,32 @@ createMutation, } from '@tanstack/svelte-query' - let intervalMs = 1000 - let value = '' + let intervalMs = $state(1000) + let value = $state('') const client = useQueryClient() const endpoint = '/api/data' - $: todos = createQuery<{ items: string[] }>({ + const todos = createQuery<{ items: string[] }>(() => ({ queryKey: ['refetch'], queryFn: async () => await fetch(endpoint).then((r) => r.json()), // Refetch the data every second refetchInterval: intervalMs, - }) + })) - const addMutation = createMutation({ + const addMutation = createMutation(() => ({ mutationFn: (value: string) => fetch(`${endpoint}?add=${encodeURIComponent(value)}`).then((r) => r.json(), ), onSuccess: () => client.invalidateQueries({ queryKey: ['refetch'] }), - }) + })) - const clearMutation = createMutation({ + const clearMutation = createMutation(() => ({ mutationFn: () => fetch(`${endpoint}?clear=1`).then((r) => r.json()), onSuccess: () => client.invalidateQueries({ queryKey: ['refetch'] }), - }) + }))

Auto Refetch with stale-time set to {intervalMs}ms

@@ -51,8 +51,8 @@ margin-left:.5rem; width:.75rem; height:.75rem; - background: {$todos.isFetching ? 'green' : 'transparent'}; - transition: {!$todos.isFetching ? 'all .3s ease' : 'none'}; + background: {todos.isFetching ? 'green' : 'transparent'}; + transition: {!todos.isFetching ? 'all .3s ease' : 'none'}; border-radius: 100%; transform: scale(1.5)" > @@ -60,10 +60,10 @@

Todo List

{ + onsubmit={(e) => { e.preventDefault() e.stopPropagation() - $addMutation.mutate(value, { + addMutation.mutate(value, { onSuccess: () => (value = ''), }) }} @@ -71,33 +71,39 @@
-{#if $todos.isPending} +{#if todos.isPending} Loading... {/if} -{#if $todos.error} +{#if todos.error} An error has occurred: - {$todos.error.message} + {todos.error.message} {/if} -{#if $todos.isSuccess} +{#if todos.isSuccess}
    - {#each $todos.data.items as item} + {#each todos.data.items as item}
  • {item}
  • {/each}
- -
-{/if} -{#if $todos.isFetching} -
- 'Background Updating...' : ' ' +
{/if} +
Background Updating...
+ diff --git a/examples/svelte/auto-refetching/svelte.config.js b/examples/svelte/auto-refetching/svelte.config.js index 3ddb57f8c4..d6b43b0085 100644 --- a/examples/svelte/auto-refetching/svelte.config.js +++ b/examples/svelte/auto-refetching/svelte.config.js @@ -7,6 +7,9 @@ const config = { kit: { adapter: adapter(), }, + compilerOptions: { + runes: true, + }, } export default config diff --git a/examples/svelte/basic/src/lib/Post.svelte b/examples/svelte/basic/src/lib/Post.svelte index 56472ce68f..49469c2fb5 100644 --- a/examples/svelte/basic/src/lib/Post.svelte +++ b/examples/svelte/basic/src/lib/Post.svelte @@ -3,29 +3,29 @@ import { getPostById } from './data' import type { Post } from './types' - export let postId: number + const { postId }: { postId: number } = $props() - const post = createQuery({ + const post = createQuery(() => ({ queryKey: ['post', postId], queryFn: () => getPostById(postId), - }) + }))
- {#if !postId || $post.isPending} + {#if !postId || post.isPending} Loading... {/if} - {#if $post.error} - Error: {$post.error.message} + {#if post.error} + Error: {post.error.message} {/if} - {#if $post.isSuccess} -

{$post.data.title}

+ {#if post.isSuccess} +

{post.data.title}

-

{$post.data.body}

+

{post.data.body}

-
{$post.isFetching ? 'Background Updating...' : ' '}
+
{post.isFetching ? 'Background Updating...' : ' '}
{/if}
diff --git a/examples/svelte/basic/src/lib/Posts.svelte b/examples/svelte/basic/src/lib/Posts.svelte index c4f2f2c642..1f19e7fe32 100644 --- a/examples/svelte/basic/src/lib/Posts.svelte +++ b/examples/svelte/basic/src/lib/Posts.svelte @@ -9,21 +9,21 @@ const posts = createQuery< { id: number; title: string; body: string }[], Error - >({ + >(() => ({ queryKey: ['posts', limit], queryFn: () => getPosts(limit), - }) + }))
- {#if $posts.status === 'pending'} + {#if posts.status === 'pending'} Loading... - {:else if $posts.status === 'error'} - Error: {$posts.error.message} + {:else if posts.status === 'error'} + Error: {posts.error.message} {:else}
    - {#each $posts.data as post} + {#each posts.data as post}
- {#if $posts.isFetching} -
- Background Updating... -
- {/if} +
Background Updating...
{/if}
@@ -53,8 +51,16 @@ } a { display: block; - color: white; font-size: 1.5rem; margin-bottom: 1rem; } + + .updating-text { + color: transparent; + transition: all 0.3s ease; + } + .updating-text.on { + color: green; + transition: none; + } diff --git a/examples/svelte/basic/src/routes/+layout.svelte b/examples/svelte/basic/src/routes/+layout.svelte index bfcc02cc1f..9ccf7bc9e6 100644 --- a/examples/svelte/basic/src/routes/+layout.svelte +++ b/examples/svelte/basic/src/routes/+layout.svelte @@ -6,6 +6,8 @@ import { PersistQueryClientProvider } from '@tanstack/svelte-query-persist-client' import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister' + const { children } = $props() + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -21,7 +23,7 @@
- + {@render children()}
diff --git a/examples/svelte/basic/src/routes/[postId]/+page.svelte b/examples/svelte/basic/src/routes/[postId]/+page.svelte index b68acc0bc0..2400baac2a 100644 --- a/examples/svelte/basic/src/routes/[postId]/+page.svelte +++ b/examples/svelte/basic/src/routes/[postId]/+page.svelte @@ -1,8 +1,7 @@ diff --git a/examples/svelte/basic/svelte.config.js b/examples/svelte/basic/svelte.config.js index 3ddb57f8c4..d6b43b0085 100644 --- a/examples/svelte/basic/svelte.config.js +++ b/examples/svelte/basic/svelte.config.js @@ -7,6 +7,9 @@ const config = { kit: { adapter: adapter(), }, + compilerOptions: { + runes: true, + }, } export default config diff --git a/examples/svelte/load-more-infinite-scroll/src/app.css b/examples/svelte/load-more-infinite-scroll/src/app.css index c57658b1ef..d301f1b2a3 100644 --- a/examples/svelte/load-more-infinite-scroll/src/app.css +++ b/examples/svelte/load-more-infinite-scroll/src/app.css @@ -48,7 +48,7 @@ main { text-align: center; } -button { +.button { border-radius: 8px; border: 1px solid transparent; padding: 0.6em 1.2em; @@ -59,11 +59,11 @@ button { cursor: pointer; transition: border-color 0.25s; } -button:hover { +.button:hover { border-color: #646cff; } -button:focus, -button:focus-visible { +.button:focus, +.button:focus-visible { outline: 4px auto -webkit-focus-ring-color; } @@ -75,7 +75,7 @@ button:focus-visible { a:hover { color: #747bff; } - button { + .button { background-color: #f9f9f9; } } diff --git a/examples/svelte/load-more-infinite-scroll/src/lib/LoadMore.svelte b/examples/svelte/load-more-infinite-scroll/src/lib/LoadMore.svelte index 79c602e672..c03a65441a 100644 --- a/examples/svelte/load-more-infinite-scroll/src/lib/LoadMore.svelte +++ b/examples/svelte/load-more-infinite-scroll/src/lib/LoadMore.svelte @@ -6,7 +6,7 @@ const fetchPlanets = async ({ pageParam = 1 }) => await fetch(`${endPoint}/planets/?page=${pageParam}`).then((r) => r.json()) - const query = createInfiniteQuery({ + const query = createInfiniteQuery(() => ({ queryKey: ['planets'], queryFn: ({ pageParam }) => fetchPlanets({ pageParam }), initialPageParam: 1, @@ -20,18 +20,18 @@ } return undefined }, - }) + })) -{#if $query.isPending} +{#if query.isPending} Loading... {/if} -{#if $query.error} - Error: {$query.error.message} +{#if query.error} + Error: {query.error.message} {/if} -{#if $query.isSuccess} +{#if query.isSuccess}
- {#each $query.data.pages as { results }} + {#each query.data.pages as { results }} {#each results as planet}
@@ -44,12 +44,12 @@
@@ -60,5 +60,6 @@ .card { background-color: #111; margin-bottom: 1rem; + color: rgba(255, 255, 255, 0.87); } diff --git a/examples/svelte/load-more-infinite-scroll/src/routes/+layout.svelte b/examples/svelte/load-more-infinite-scroll/src/routes/+layout.svelte index ef60a2f559..4b170dc01e 100644 --- a/examples/svelte/load-more-infinite-scroll/src/routes/+layout.svelte +++ b/examples/svelte/load-more-infinite-scroll/src/routes/+layout.svelte @@ -4,6 +4,8 @@ import { QueryClientProvider, QueryClient } from '@tanstack/svelte-query' import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools' + const { children } = $props() + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -15,7 +17,7 @@
- + {@render children()}
diff --git a/examples/svelte/load-more-infinite-scroll/svelte.config.js b/examples/svelte/load-more-infinite-scroll/svelte.config.js index 0aa6cba937..d6b43b0085 100644 --- a/examples/svelte/load-more-infinite-scroll/svelte.config.js +++ b/examples/svelte/load-more-infinite-scroll/svelte.config.js @@ -3,13 +3,13 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' /** @type {import('@sveltejs/kit').Config} */ const config = { - // Consult https://kit.svelte.dev/docs/integrations#preprocessors - // for more information about preprocessors preprocess: vitePreprocess(), - kit: { adapter: adapter(), }, + compilerOptions: { + runes: true, + }, } export default config diff --git a/examples/svelte/optimistic-updates/src/routes/+layout.svelte b/examples/svelte/optimistic-updates/src/routes/+layout.svelte index ef60a2f559..4b170dc01e 100644 --- a/examples/svelte/optimistic-updates/src/routes/+layout.svelte +++ b/examples/svelte/optimistic-updates/src/routes/+layout.svelte @@ -4,6 +4,8 @@ import { QueryClientProvider, QueryClient } from '@tanstack/svelte-query' import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools' + const { children } = $props() + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -15,7 +17,7 @@
- + {@render children()}
diff --git a/examples/svelte/optimistic-updates/src/routes/+page.svelte b/examples/svelte/optimistic-updates/src/routes/+page.svelte index 0bbd37ee68..0caf5ffe7b 100644 --- a/examples/svelte/optimistic-updates/src/routes/+page.svelte +++ b/examples/svelte/optimistic-updates/src/routes/+page.svelte @@ -16,7 +16,7 @@ ts: number } - let text = '' + let text = $state('') const client = useQueryClient() @@ -36,12 +36,12 @@ }), }).then((res) => res.json()) - const todos = createQuery({ + const todos = createQuery(() => ({ queryKey: ['optimistic'], queryFn: fetchTodos, - }) + })) - const addTodoMutation = createMutation({ + const addTodoMutation = createMutation(() => ({ mutationFn: createTodo, onMutate: async (newTodo: string) => { text = '' @@ -74,7 +74,7 @@ onSettled: () => { client.invalidateQueries({ queryKey: ['optimistic'] }) }, - }) + }))

Optimistic Updates

@@ -87,36 +87,36 @@

{ + onsubmit={(e) => { e.preventDefault() e.stopPropagation() - $addTodoMutation.mutate(text) + addTodoMutation.mutate(text) }} >
- +
-{#if $todos.isPending} +{#if todos.isPending} Loading... {/if} -{#if $todos.error} +{#if todos.error} An error has occurred: - {$todos.error.message} + {todos.error.message} {/if} -{#if $todos.isSuccess} +{#if todos.isSuccess}
- Updated At: {new Date($todos.data.ts).toLocaleTimeString()} + Updated At: {new Date(todos.data.ts).toLocaleTimeString()}
    - {#each $todos.data.items as todo} + {#each todos.data.items as todo}
  • {todo.text}
  • {/each}
{/if} -{#if $todos.isFetching} +{#if todos.isFetching}
'Background Updating...' : ' '
diff --git a/examples/svelte/optimistic-updates/svelte.config.js b/examples/svelte/optimistic-updates/svelte.config.js index 3ddb57f8c4..d6b43b0085 100644 --- a/examples/svelte/optimistic-updates/svelte.config.js +++ b/examples/svelte/optimistic-updates/svelte.config.js @@ -7,6 +7,9 @@ const config = { kit: { adapter: adapter(), }, + compilerOptions: { + runes: true, + }, } export default config diff --git a/examples/svelte/playground/src/lib/stores.svelte.ts b/examples/svelte/playground/src/lib/stores.svelte.ts new file mode 100644 index 0000000000..18f0232ebb --- /dev/null +++ b/examples/svelte/playground/src/lib/stores.svelte.ts @@ -0,0 +1,37 @@ +export function ref(initial: T) { + let value = $state(initial) + + return { + get value() { + return value + }, + set value(newValue) { + value = newValue + }, + } +} + +export const staleTime = ref(1000) +export const gcTime = ref(3000) +export const errorRate = ref(0.05) +export const queryTimeMin = ref(1000) +export const queryTimeMax = ref(2000) + +export const editingIndex = ref(null) +export const views = ref(['', 'fruit', 'grape']) + +let initialId = 0 +const initialList = [ + 'apple', + 'banana', + 'pineapple', + 'grapefruit', + 'dragonfruit', + 'grapes', +].map((d) => ({ id: initialId++, name: d, notes: 'These are some notes' })) + +export const list = ref(initialList) +export const id = ref(initialId) + +export type Todos = typeof initialList +export type Todo = Todos[0] diff --git a/examples/svelte/playground/src/lib/stores.ts b/examples/svelte/playground/src/lib/stores.ts deleted file mode 100644 index 2dcdd669a6..0000000000 --- a/examples/svelte/playground/src/lib/stores.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { writable } from 'svelte/store' - -export const staleTime = writable(1000) -export const gcTime = writable(3000) -export const errorRate = writable(0.05) -export const queryTimeMin = writable(1000) -export const queryTimeMax = writable(2000) - -export const editingIndex = writable(null) -export const views = writable(['', 'fruit', 'grape']) - -let initialId = 0 -const initialList = [ - 'apple', - 'banana', - 'pineapple', - 'grapefruit', - 'dragonfruit', - 'grapes', -].map((d) => ({ id: initialId++, name: d, notes: 'These are some notes' })) - -export const list = writable(initialList) -export const id = writable(initialId) - -export type Todos = typeof initialList -export type Todo = Todos[0] diff --git a/examples/svelte/playground/src/routes/+layout.svelte b/examples/svelte/playground/src/routes/+layout.svelte index 1b9267032c..76c2d0d277 100644 --- a/examples/svelte/playground/src/routes/+layout.svelte +++ b/examples/svelte/playground/src/routes/+layout.svelte @@ -4,6 +4,8 @@ import { QueryClientProvider, QueryClient } from '@tanstack/svelte-query' import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools' + const { children } = $props() + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -19,7 +21,7 @@
- + {@render children()}
diff --git a/examples/svelte/playground/src/routes/+page.svelte b/examples/svelte/playground/src/routes/+page.svelte index 4830d01eb3..cec4a24208 100644 --- a/examples/svelte/playground/src/routes/+page.svelte +++ b/examples/svelte/playground/src/routes/+page.svelte @@ -6,15 +6,15 @@ errorRate, queryTimeMin, queryTimeMax, - } from '../lib/stores' + } from '../lib/stores.svelte' import App from './App.svelte' const queryClient = useQueryClient() queryClient.setDefaultOptions({ queries: { - staleTime: $staleTime, - gcTime: $gcTime, + staleTime: staleTime.value, + gcTime: gcTime.value, }, }) @@ -29,7 +29,7 @@ type="number" min="0" step="1000" - bind:value={$staleTime} + bind:value={staleTime.value} style="width: 100px" />
@@ -39,7 +39,7 @@ type="number" min="0" step="1000" - bind:value={$gcTime} + bind:value={gcTime.value} style="width: 100px" />
@@ -51,7 +51,7 @@ min="0" max="1" step=".05" - bind:value={$errorRate} + bind:value={errorRate.value} style="width: 100px" />
@@ -61,7 +61,7 @@ type="number" min="1" step="500" - bind:value={$queryTimeMin} + bind:value={queryTimeMin.value} style="width: 100px" />{' '}
@@ -71,7 +71,7 @@ type="number" min="1" step="500" - bind:value={$queryTimeMax} + bind:value={queryTimeMax.value} style="width: 100px" /> diff --git a/examples/svelte/playground/src/routes/AddTodo.svelte b/examples/svelte/playground/src/routes/AddTodo.svelte index 5fe8fc4dcd..f482f6c2f1 100644 --- a/examples/svelte/playground/src/routes/AddTodo.svelte +++ b/examples/svelte/playground/src/routes/AddTodo.svelte @@ -1,55 +1,67 @@
- +
- {$addMutation.status === 'pending' + {addMutation.status === 'pending' ? 'Saving...' - : $addMutation.status === 'error' - ? $addMutation.error.message + : addMutation.status === 'error' + ? addMutation.error.message : 'Saved!'}
diff --git a/examples/svelte/playground/src/routes/App.svelte b/examples/svelte/playground/src/routes/App.svelte index 5a72f454c0..bd909aae90 100644 --- a/examples/svelte/playground/src/routes/App.svelte +++ b/examples/svelte/playground/src/routes/App.svelte @@ -3,21 +3,21 @@ import TodosList from './TodosList.svelte' import EditTodo from './EditTodo.svelte' import AddTodo from './AddTodo.svelte' - import { views, editingIndex } from '../lib/stores' + import { views, editingIndex } from '../lib/stores.svelte' const queryClient = useQueryClient()
-


- {#each $views as view} + {#each views.value as view}

@@ -25,15 +25,15 @@ {/each}
- {#if $editingIndex !== null} + {#if editingIndex.value !== null}
{/if} diff --git a/examples/svelte/playground/src/routes/EditTodo.svelte b/examples/svelte/playground/src/routes/EditTodo.svelte index 40b2208e60..d38c232221 100644 --- a/examples/svelte/playground/src/routes/EditTodo.svelte +++ b/examples/svelte/playground/src/routes/EditTodo.svelte @@ -10,23 +10,21 @@ queryTimeMax, list, editingIndex, - } from '$lib/stores' - import { derived } from 'svelte/store' - import type { Todo } from '$lib/stores' + } from '$lib/stores.svelte' + import type { Todo } from '$lib/stores.svelte' const queryClient = useQueryClient() const fetchTodoById = async ({ id }: { id: number }): Promise => { - console.info('fetchTodoById', { id }) return new Promise((resolve, reject) => { setTimeout( () => { - if (Math.random() < $errorRate) { + if (Math.random() < errorRate.value) { return reject( new Error(JSON.stringify({ fetchTodoById: { id } }, null, 2)), ) } - const todo = $list.find((d) => d.id === id) + const todo = $state.snapshot(list.value.find((d) => d.id === id)) if (!todo) { return reject( new Error(JSON.stringify({ fetchTodoById: { id } }, null, 2)), @@ -34,7 +32,8 @@ } resolve(todo) }, - $queryTimeMin + Math.random() * ($queryTimeMax - $queryTimeMin), + queryTimeMin.value + + Math.random() * (queryTimeMax.value - queryTimeMin.value), ) }) } @@ -44,7 +43,7 @@ return new Promise((resolve, reject) => { setTimeout( () => { - if (Math.random() < $errorRate) { + if (Math.random() < errorRate.value) { return reject( new Error(JSON.stringify({ patchTodo: todo }, null, 2)), ) @@ -54,60 +53,58 @@ new Error(JSON.stringify({ patchTodo: todo }, null, 2)), ) } - list.set( - $list.map((d) => { - if (d.id === todo.id) { - return todo - } - return d - }), - ) + list.value = list.value.map((d) => { + if (d.id === todo.id) { + return $state.snapshot(todo) + } + return d + }) resolve(todo) }, - $queryTimeMin + Math.random() * ($queryTimeMax - $queryTimeMin), + queryTimeMin.value + + Math.random() * (queryTimeMax.value - queryTimeMin.value), ) }) } - const query = createQuery( - derived(editingIndex, ($editingIndex) => ({ - queryKey: ['todo', { id: $editingIndex }], - queryFn: () => fetchTodoById({ id: $editingIndex || 0 }), - enabled: $editingIndex !== null, - })), - ) + const query = createQuery(() => ({ + queryKey: ['todo', { id: editingIndex.value }], + queryFn: () => fetchTodoById({ id: editingIndex.value || 0 }), + enabled: editingIndex.value !== null, + })) - const saveMutation = createMutation({ + const saveMutation = createMutation(() => ({ mutationFn: patchTodo, onSuccess: (data) => { // Update `todos` and the individual todo queries when this mutation succeeds queryClient.invalidateQueries({ queryKey: ['todos'] }) queryClient.setQueryData(['todo', { id: editingIndex }], data) }, - }) + })) - $: todo = $query.data + const todo = $derived(query.data) const onSave = () => { - $saveMutation.mutate(todo) + saveMutation.mutate(todo) } - $: disableEditSave = - $query.status === 'pending' || $saveMutation.status === 'pending' + const disableEditSave = $derived( + query.status === 'pending' || saveMutation.status === 'pending', + )
- {#if $query.data} - Editing Todo - "{$query.data.name}" (#{$editingIndex}) + {#if query.data} + Editing + Todo "{query.data.name}" (#{editingIndex.value}) {/if}
- {#if $query.status === 'pending'} - Loading... (Attempt: {$query.failureCount + 1}) - {:else if $query.error} + {#if query.status === 'pending'} + Loading... (Attempt: {query.failureCount + 1}) + {:else if query.error} - Error! + Error! {:else if todo}
- +
- {$saveMutation.status === 'pending' + {saveMutation.status === 'pending' ? 'Saving...' - : $saveMutation.status === 'error' - ? $saveMutation.error.message + : saveMutation.status === 'error' + ? saveMutation.error.message : 'Saved!'}
- {#if $query.isFetching} + {#if query.isFetching} - Background Refreshing... (Attempt: {$query.failureCount + 1}) + Background Refreshing... (Attempt: {query.failureCount + 1}) {:else}   diff --git a/examples/svelte/playground/src/routes/TodosList.svelte b/examples/svelte/playground/src/routes/TodosList.svelte index 586af38e35..a7d6f392cc 100644 --- a/examples/svelte/playground/src/routes/TodosList.svelte +++ b/examples/svelte/playground/src/routes/TodosList.svelte @@ -6,68 +6,66 @@ queryTimeMax, list, editingIndex, - } from '$lib/stores' - import { derived, writable } from 'svelte/store' - import type { Todos } from '$lib/stores' + } from '$lib/stores.svelte' + import type { Todos } from '$lib/stores.svelte' - export let initialFilter: string + let { initialFilter }: { initialFilter: string } = $props() - let filter = writable(initialFilter) + let filter = $state(initialFilter) const fetchTodos = async ({ filter }: { filter: string }): Promise => { return new Promise((resolve, reject) => { setTimeout( () => { - if (Math.random() < $errorRate) { + if (Math.random() < errorRate.value) { return reject( new Error(JSON.stringify({ fetchTodos: { filter } }, null, 2)), ) } - resolve($list.filter((d) => d.name.includes(filter))) + resolve(list.value.filter((d) => d.name.includes(filter))) }, - $queryTimeMin + Math.random() * ($queryTimeMax - $queryTimeMin), + queryTimeMin.value + + Math.random() * (queryTimeMax.value - queryTimeMin.value), ) }) } - const query = createQuery( - derived(filter, ($filter) => ({ - queryKey: ['todos', { filter: $filter }], - queryFn: () => fetchTodos({ filter: $filter }), - })), - ) + const query = createQuery(() => ({ + queryKey: ['todos', { filter: filter }], + queryFn: () => fetchTodos({ filter: filter }), + }))
-{#if $query.status === 'pending'} - Loading... (Attempt: {$query.failureCount + 1}) -{:else if $query.status === 'error'} +{#if query.status === 'pending'} + Loading... (Attempt: {query.failureCount + 1}) +{:else if query.status === 'error'} - Error: {$query.error.message} + Error: {query.error.message}
- +
{:else}
    - {#if $query.data} - {#each $query.data as todo} + {#if query.data} + {#each query.data as todo}
  • {todo.name}{' '} - +
  • {/each} {/if}
- {#if $query.isFetching} + {#if query.isFetching} - Background Refreshing... (Attempt: {$query.failureCount + 1}) + Background Refreshing... (Attempt: {query.failureCount + 1}) {:else}   diff --git a/examples/svelte/playground/svelte.config.js b/examples/svelte/playground/svelte.config.js index 3ddb57f8c4..d6b43b0085 100644 --- a/examples/svelte/playground/svelte.config.js +++ b/examples/svelte/playground/svelte.config.js @@ -7,6 +7,9 @@ const config = { kit: { adapter: adapter(), }, + compilerOptions: { + runes: true, + }, } export default config diff --git a/examples/svelte/simple/src/lib/Simple.svelte b/examples/svelte/simple/src/lib/Simple.svelte index 6b044dc321..a467e91393 100644 --- a/examples/svelte/simple/src/lib/Simple.svelte +++ b/examples/svelte/simple/src/lib/Simple.svelte @@ -9,32 +9,32 @@ forks_count: number } - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['repoData'], queryFn: async () => await fetch('https://api.github.com/repos/TanStack/query').then((r) => r.json(), ), - }) + }))

Simple

- {#if $query.isPending} + {#if query.isPending} Loading... {/if} - {#if $query.error} + {#if query.error} An error has occurred: - {$query.error.message} + {query.error.message} {/if} - {#if $query.isSuccess} + {#if query.isSuccess}
-

{$query.data.full_name}

-

{$query.data.description}

- 👀 {$query.data.subscribers_count}{' '} - ✨ {$query.data.stargazers_count}{' '} - 🍴 {$query.data.forks_count} +

{query.data.full_name}

+

{query.data.description}

+ 👀 {query.data.subscribers_count}{' '} + ✨ {query.data.stargazers_count}{' '} + 🍴 {query.data.forks_count}
{/if}
diff --git a/examples/svelte/simple/src/main.ts b/examples/svelte/simple/src/main.ts index 7ad46094a0..eeb0a0bcec 100644 --- a/examples/svelte/simple/src/main.ts +++ b/examples/svelte/simple/src/main.ts @@ -1,7 +1,8 @@ +import { mount } from 'svelte' import './app.css' import App from './App.svelte' -const app = new App({ +const app = mount(App, { target: document.querySelector('#app')!, }) diff --git a/examples/svelte/simple/svelte.config.js b/examples/svelte/simple/svelte.config.js index 8abe4369b8..64c513012f 100644 --- a/examples/svelte/simple/svelte.config.js +++ b/examples/svelte/simple/svelte.config.js @@ -2,4 +2,7 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' export default { preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, } diff --git a/examples/svelte/ssr/src/lib/Post.svelte b/examples/svelte/ssr/src/lib/Post.svelte index 0de658b6dd..10b4df1949 100644 --- a/examples/svelte/ssr/src/lib/Post.svelte +++ b/examples/svelte/ssr/src/lib/Post.svelte @@ -3,29 +3,29 @@ import { api } from './api' import type { Post } from './types' - export let postId: number + const { postId }: { postId: number } = $props() - const post = createQuery({ + const post = createQuery(() => ({ queryKey: ['post', postId], queryFn: () => api().getPostById(postId), - }) + }))
diff --git a/examples/svelte/ssr/src/lib/Posts.svelte b/examples/svelte/ssr/src/lib/Posts.svelte index 7457dfd49b..5de76de86a 100644 --- a/examples/svelte/ssr/src/lib/Posts.svelte +++ b/examples/svelte/ssr/src/lib/Posts.svelte @@ -4,26 +4,26 @@ const client = useQueryClient() - let limit = 10 + const limit = 10 const posts = createQuery< { id: number; title: string; body: string }[], Error - >({ + >(() => ({ queryKey: ['posts', limit], queryFn: () => api().getPosts(limit), - }) + })) @@ -53,8 +51,15 @@ } a { display: block; - color: white; font-size: 1.5rem; margin-bottom: 1rem; } + .updating-text { + color: transparent; + transition: all 0.3s ease; + } + .updating-text.on { + color: green; + transition: none; + } diff --git a/examples/svelte/ssr/src/routes/+layout.svelte b/examples/svelte/ssr/src/routes/+layout.svelte index d639174e3f..0b02447853 100644 --- a/examples/svelte/ssr/src/routes/+layout.svelte +++ b/examples/svelte/ssr/src/routes/+layout.svelte @@ -2,14 +2,13 @@ import '../app.css' import { QueryClientProvider } from '@tanstack/svelte-query' import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools' - import type { PageData } from './$types' - export let data: PageData + const { data, children } = $props()
- + {@render children()}
diff --git a/examples/svelte/ssr/src/routes/+layout.ts b/examples/svelte/ssr/src/routes/+layout.ts index 5104825207..f922afcc92 100644 --- a/examples/svelte/ssr/src/routes/+layout.ts +++ b/examples/svelte/ssr/src/routes/+layout.ts @@ -1,6 +1,6 @@ -import { browser } from '$app/environment' import { QueryClient } from '@tanstack/svelte-query' import type { LayoutLoad } from './$types' +import { browser } from '$app/environment' export const load: LayoutLoad = () => { const queryClient = new QueryClient({ diff --git a/examples/svelte/ssr/src/routes/+page.ts b/examples/svelte/ssr/src/routes/+page.ts index 22d8f8ffbe..811b0d3a14 100644 --- a/examples/svelte/ssr/src/routes/+page.ts +++ b/examples/svelte/ssr/src/routes/+page.ts @@ -1,5 +1,5 @@ -import { api } from '$lib/api' import type { PageLoad } from './$types' +import { api } from '$lib/api' export const load: PageLoad = async ({ parent, fetch }) => { const { queryClient } = await parent() diff --git a/examples/svelte/ssr/src/routes/[postId]/+page.svelte b/examples/svelte/ssr/src/routes/[postId]/+page.svelte index b68acc0bc0..2400baac2a 100644 --- a/examples/svelte/ssr/src/routes/[postId]/+page.svelte +++ b/examples/svelte/ssr/src/routes/[postId]/+page.svelte @@ -1,8 +1,7 @@ diff --git a/examples/svelte/ssr/src/routes/[postId]/+page.ts b/examples/svelte/ssr/src/routes/[postId]/+page.ts index b9cca0729b..87c9fa8a43 100644 --- a/examples/svelte/ssr/src/routes/[postId]/+page.ts +++ b/examples/svelte/ssr/src/routes/[postId]/+page.ts @@ -1,5 +1,5 @@ -import { api } from '$lib/api' import type { PageLoad } from './$types' +import { api } from '$lib/api' export const load: PageLoad = async ({ parent, fetch, params }) => { const { queryClient } = await parent() diff --git a/examples/svelte/ssr/svelte.config.js b/examples/svelte/ssr/svelte.config.js index 3ddb57f8c4..d6b43b0085 100644 --- a/examples/svelte/ssr/svelte.config.js +++ b/examples/svelte/ssr/svelte.config.js @@ -7,6 +7,9 @@ const config = { kit: { adapter: adapter(), }, + compilerOptions: { + runes: true, + }, } export default config diff --git a/examples/svelte/star-wars/src/routes/+layout.svelte b/examples/svelte/star-wars/src/routes/+layout.svelte index 6dc4a583c7..925e53ef88 100644 --- a/examples/svelte/star-wars/src/routes/+layout.svelte +++ b/examples/svelte/star-wars/src/routes/+layout.svelte @@ -4,6 +4,8 @@ import { QueryClientProvider, QueryClient } from '@tanstack/svelte-query' import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools' + const { children } = $props() + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -26,7 +28,7 @@
Characters
- + {@render children()}
diff --git a/examples/svelte/star-wars/src/routes/characters/+page.svelte b/examples/svelte/star-wars/src/routes/characters/+page.svelte index 560112ca19..a9698aa301 100644 --- a/examples/svelte/star-wars/src/routes/characters/+page.svelte +++ b/examples/svelte/star-wars/src/routes/characters/+page.svelte @@ -2,24 +2,24 @@ import { createQuery } from '@tanstack/svelte-query' import { getCharacters } from '$lib/api' - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['characters'], queryFn: () => getCharacters(), - }) + })) -{#if $query.status === 'pending'} +{#if query.status === 'pending'}

Loading...

{/if} -{#if $query.status === 'error'} +{#if query.status === 'error'}

Error :(

{/if} -{#if $query.status === 'success'} +{#if query.status === 'success'}

Characters

- {#each $query.data.results as person} + {#each query.data.results as person} {@const personUrlParts = person.url.split('/').filter(Boolean)} {@const personId = personUrlParts[personUrlParts.length - 1]}
diff --git a/examples/svelte/star-wars/src/routes/characters/[characterId]/+page.svelte b/examples/svelte/star-wars/src/routes/characters/[characterId]/+page.svelte index 810082cbe0..fdcfd59456 100644 --- a/examples/svelte/star-wars/src/routes/characters/[characterId]/+page.svelte +++ b/examples/svelte/star-wars/src/routes/characters/[characterId]/+page.svelte @@ -3,37 +3,35 @@ import Homeworld from './Homeworld.svelte' import Film from './Film.svelte' import { getCharacter } from '$lib/api' - import type { PageData } from './$types' + import { page } from '$app/state' - export let data: PageData - - const query = createQuery({ - queryKey: ['character', data.params.characterId], - queryFn: () => getCharacter(data.params.characterId), - }) + const query = createQuery(() => ({ + queryKey: ['character', page.params.characterId], + queryFn: () => getCharacter(page.params.characterId), + })) -{#if $query.status === 'pending'} +{#if query.status === 'pending'}

Loading...

{/if} -{#if $query.status === 'error'} +{#if query.status === 'error'}

Error :(

{/if} -{#if $query.status === 'success'} - {@const homeworldUrlParts = $query.data.homeworld.split('/').filter(Boolean)} +{#if query.status === 'success'} + {@const homeworldUrlParts = query.data.homeworld.split('/').filter(Boolean)} {@const homeworldId = homeworldUrlParts[homeworldUrlParts.length - 1]} -

{$query.data.name}

+

{query.data.name}

-

Born: {$query.data.birth_year}

-

Eyes: {$query.data.eye_color}

-

Hair: {$query.data.hair_color}

-

Height: {$query.data.height}

-

Mass: {$query.data.mass}

+

Born: {query.data.birth_year}

+

Eyes: {query.data.eye_color}

+

Hair: {query.data.hair_color}

+

Height: {query.data.height}

+

Mass: {query.data.mass}

Homeworld:

Films

- {#each $query.data.films as film} + {#each query.data.films as film} {@const filmUrlParts = film.split('/').filter(Boolean)} {@const filmId = filmUrlParts[filmUrlParts.length - 1]} diff --git a/examples/svelte/star-wars/src/routes/characters/[characterId]/+page.ts b/examples/svelte/star-wars/src/routes/characters/[characterId]/+page.ts deleted file mode 100644 index dbfde8eb56..0000000000 --- a/examples/svelte/star-wars/src/routes/characters/[characterId]/+page.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PageLoad } from './$types' - -export const load: PageLoad = ({ params }) => { - return { params } -} diff --git a/examples/svelte/star-wars/src/routes/characters/[characterId]/Film.svelte b/examples/svelte/star-wars/src/routes/characters/[characterId]/Film.svelte index ec8a67562d..4c427e9ab3 100644 --- a/examples/svelte/star-wars/src/routes/characters/[characterId]/Film.svelte +++ b/examples/svelte/star-wars/src/routes/characters/[characterId]/Film.svelte @@ -2,16 +2,16 @@ import { getFilm } from '$lib/api' import { createQuery } from '@tanstack/svelte-query' - export let filmId: string + let { filmId }: { filmId: string } = $props() - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['film', filmId], queryFn: () => getFilm(filmId), - }) + })) -{#if $query.status === 'success'} +{#if query.status === 'success'} -
{$query.data.episode_id}. {$query.data.title}
+
{query.data.episode_id}. {query.data.title}
{/if} diff --git a/examples/svelte/star-wars/src/routes/characters/[characterId]/Homeworld.svelte b/examples/svelte/star-wars/src/routes/characters/[characterId]/Homeworld.svelte index a90e9cbf0a..5c68079065 100644 --- a/examples/svelte/star-wars/src/routes/characters/[characterId]/Homeworld.svelte +++ b/examples/svelte/star-wars/src/routes/characters/[characterId]/Homeworld.svelte @@ -2,24 +2,24 @@ import { createQuery } from '@tanstack/svelte-query' import { getPlanet } from '$lib/api' - export let homeworldId: string + let { homeworldId }: { homeworldId: string } = $props() - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['homeworld', homeworldId], queryFn: () => getPlanet(homeworldId), - }) + })) -{#if $query.status === 'pending'} +{#if query.status === 'pending'} Loading... {/if} -{#if $query.status === 'error'} +{#if query.status === 'error'} Error :( {/if} -{#if $query.status === 'success'} +{#if query.status === 'success'} - {$query.data.name} + {query.data.name} {/if} diff --git a/examples/svelte/star-wars/src/routes/films/+page.svelte b/examples/svelte/star-wars/src/routes/films/+page.svelte index c85ad557d1..c2a6c7cae7 100644 --- a/examples/svelte/star-wars/src/routes/films/+page.svelte +++ b/examples/svelte/star-wars/src/routes/films/+page.svelte @@ -2,24 +2,24 @@ import { getFilms } from '$lib/api' import { createQuery } from '@tanstack/svelte-query' - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['films'], queryFn: () => getFilms(), - }) + })) -{#if $query.status === 'pending'} +{#if query.status === 'pending'}

Loading...

{/if} -{#if $query.status === 'error'} +{#if query.status === 'error'}

Error :(

{/if} -{#if $query.status === 'success'} +{#if query.status === 'success'}

Films

- {#each $query.data.results as film} + {#each query.data.results as film} {@const filmUrlParts = film.url.split('/').filter(Boolean)} {@const filmId = filmUrlParts[filmUrlParts.length - 1]}
diff --git a/examples/svelte/star-wars/src/routes/films/[filmId]/+page.svelte b/examples/svelte/star-wars/src/routes/films/[filmId]/+page.svelte index 94d68702b8..d8b83d3a9a 100644 --- a/examples/svelte/star-wars/src/routes/films/[filmId]/+page.svelte +++ b/examples/svelte/star-wars/src/routes/films/[filmId]/+page.svelte @@ -2,31 +2,29 @@ import { createQuery } from '@tanstack/svelte-query' import Character from './Character.svelte' import { getFilm } from '$lib/api' - import type { PageData } from './$types' + import { page } from '$app/state' - export let data: PageData - - const query = createQuery({ - queryKey: ['film', data.params.filmId], - queryFn: () => getFilm(data.params.filmId), - }) + const query = createQuery(() => ({ + queryKey: ['film', page.params.filmId], + queryFn: () => getFilm(page.params.filmId), + })) -{#if $query.status === 'pending'} +{#if query.status === 'pending'}

Loading...

{/if} -{#if $query.status === 'error'} +{#if query.status === 'error'}

Error :(

{/if} -{#if $query.status === 'success'} +{#if query.status === 'success'}
-

{$query.data.title}

-

{$query.data.opening_crawl}

+

{query.data.title}

+

{query.data.opening_crawl}


Characters

- {#each $query.data.characters as character} + {#each query.data.characters as character} {@const characterUrlParts = character.split('/').filter(Boolean)} {@const characterId = characterUrlParts[characterUrlParts.length - 1]} diff --git a/examples/svelte/star-wars/src/routes/films/[filmId]/+page.ts b/examples/svelte/star-wars/src/routes/films/[filmId]/+page.ts deleted file mode 100644 index dbfde8eb56..0000000000 --- a/examples/svelte/star-wars/src/routes/films/[filmId]/+page.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PageLoad } from './$types' - -export const load: PageLoad = ({ params }) => { - return { params } -} diff --git a/examples/svelte/star-wars/src/routes/films/[filmId]/Character.svelte b/examples/svelte/star-wars/src/routes/films/[filmId]/Character.svelte index 14e439d9a6..17d2eac398 100644 --- a/examples/svelte/star-wars/src/routes/films/[filmId]/Character.svelte +++ b/examples/svelte/star-wars/src/routes/films/[filmId]/Character.svelte @@ -2,21 +2,21 @@ import { getCharacter } from '$lib/api' import { createQuery } from '@tanstack/svelte-query' - export let characterId: string + let { characterId }: { characterId: string } = $props() - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['character', characterId], queryFn: () => getCharacter(characterId), - }) + })) -{#if $query.status === 'success'} +{#if query.status === 'success'} {/if} diff --git a/examples/svelte/star-wars/svelte.config.js b/examples/svelte/star-wars/svelte.config.js index 3ddb57f8c4..d6b43b0085 100644 --- a/examples/svelte/star-wars/svelte.config.js +++ b/examples/svelte/star-wars/svelte.config.js @@ -7,6 +7,9 @@ const config = { kit: { adapter: adapter(), }, + compilerOptions: { + runes: true, + }, } export default config diff --git a/packages/svelte-query-devtools/eslint.config.js b/packages/svelte-query-devtools/eslint.config.js index b657d69d66..e102c19ee9 100644 --- a/packages/svelte-query-devtools/eslint.config.js +++ b/packages/svelte-query-devtools/eslint.config.js @@ -1,17 +1,19 @@ // @ts-check +import tsParser from '@typescript-eslint/parser' import pluginSvelte from 'eslint-plugin-svelte' import rootConfig from './root.eslint.config.js' import svelteConfig from './svelte.config.js' export default [ ...rootConfig, - ...pluginSvelte.configs['flat/recommended'], + ...pluginSvelte.configs['recommended'], { - files: ['**/*.svelte'], + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], languageOptions: { parserOptions: { - parser: '@typescript-eslint/parser', + parser: tsParser, + extraFileExtensions: ['.svelte'], svelteConfig, }, }, diff --git a/packages/svelte-query-devtools/package.json b/packages/svelte-query-devtools/package.json index fb43371d02..0b06f684b9 100644 --- a/packages/svelte-query-devtools/package.json +++ b/packages/svelte-query-devtools/package.json @@ -14,6 +14,11 @@ "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, + "keywords": [ + "tanstack", + "query", + "svelte" + ], "scripts": { "clean": "premove ./dist ./coverage ./.svelte-kit ./dist-ts", "compile": "tsc --build", @@ -48,12 +53,13 @@ "@sveltejs/package": "^2.4.0", "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tanstack/svelte-query": "workspace:*", + "@typescript-eslint/parser": "^8.44.1", "eslint-plugin-svelte": "^3.11.0", "svelte": "^5.39.3", "svelte-check": "^4.3.1" }, "peerDependencies": { "@tanstack/svelte-query": "workspace:^", - "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0" + "svelte": "^5.25.0" } } diff --git a/packages/svelte-query-devtools/src/Devtools.svelte b/packages/svelte-query-devtools/src/Devtools.svelte index ea0de0dfd0..e80064de68 100644 --- a/packages/svelte-query-devtools/src/Devtools.svelte +++ b/packages/svelte-query-devtools/src/Devtools.svelte @@ -10,14 +10,55 @@ TanstackQueryDevtools, } from '@tanstack/query-devtools' - export let initialIsOpen = false - export let buttonPosition: DevtoolsButtonPosition = 'bottom-right' - export let position: DevtoolsPosition = 'bottom' - export let client: QueryClient = useQueryClient() - export let errorTypes: Array = [] - export let styleNonce: string | undefined = undefined - export let shadowDOMTarget: ShadowRoot | undefined = undefined - export let hideDisabledQueries: boolean = false + interface DevtoolsOptions { + /** + * Set this true if you want the dev tools to default to being open + */ + initialIsOpen?: boolean + /** + * The position of the TanStack Query logo to open and close the devtools panel. + * 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' + * Defaults to 'bottom-right'. + */ + buttonPosition?: DevtoolsButtonPosition + /** + * The position of the TanStack Query devtools panel. + * 'top' | 'bottom' | 'left' | 'right' + * Defaults to 'bottom'. + */ + position?: DevtoolsPosition + /** + * Custom instance of QueryClient + */ + client?: QueryClient + /** + * Use this so you can define custom errors that can be shown in the devtools. + */ + errorTypes?: Array + /** + * Use this to pass a nonce to the style tag that is added to the document head. This is useful if you are using a Content Security Policy (CSP) nonce to allow inline styles. + */ + styleNonce?: string + /** + * Use this so you can attach the devtool's styles to specific element in the DOM. + */ + shadowDOMTarget?: ShadowRoot + /** + * Set this to true to hide disabled queries from the devtools panel. + */ + hideDisabledQueries?: boolean + } + + let { + initialIsOpen = false, + buttonPosition = 'bottom-right', + position = 'bottom', + client = useQueryClient(), + errorTypes = [], + styleNonce = undefined, + shadowDOMTarget = undefined, + hideDisabledQueries = false, + }: DevtoolsOptions = $props() let ref: HTMLDivElement let devtools: TanstackQueryDevtools | undefined @@ -43,20 +84,24 @@ devtools.mount(ref) }) + return () => devtools?.unmount() + }) - return () => { - devtools?.unmount() - } + $effect(() => { + devtools?.setButtonPosition(buttonPosition) }) - } - $: { - if (devtools) { - devtools.setButtonPosition(buttonPosition) - devtools.setPosition(position) - devtools.setInitialIsOpen(initialIsOpen) - devtools.setErrorTypes(errorTypes) - } + $effect(() => { + devtools?.setPosition(position) + }) + + $effect(() => { + devtools?.setInitialIsOpen(initialIsOpen) + }) + + $effect(() => { + devtools?.setErrorTypes(errorTypes) + }) } diff --git a/packages/svelte-query-devtools/svelte.config.js b/packages/svelte-query-devtools/svelte.config.js index 94ca454ac7..076d2dcd50 100644 --- a/packages/svelte-query-devtools/svelte.config.js +++ b/packages/svelte-query-devtools/svelte.config.js @@ -2,6 +2,9 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' const config = { preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, } export default config diff --git a/packages/svelte-query-persist-client/eslint.config.js b/packages/svelte-query-persist-client/eslint.config.js index b657d69d66..e102c19ee9 100644 --- a/packages/svelte-query-persist-client/eslint.config.js +++ b/packages/svelte-query-persist-client/eslint.config.js @@ -1,17 +1,19 @@ // @ts-check +import tsParser from '@typescript-eslint/parser' import pluginSvelte from 'eslint-plugin-svelte' import rootConfig from './root.eslint.config.js' import svelteConfig from './svelte.config.js' export default [ ...rootConfig, - ...pluginSvelte.configs['flat/recommended'], + ...pluginSvelte.configs['recommended'], { - files: ['**/*.svelte'], + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], languageOptions: { parserOptions: { - parser: '@typescript-eslint/parser', + parser: tsParser, + extraFileExtensions: ['.svelte'], svelteConfig, }, }, diff --git a/packages/svelte-query-persist-client/package.json b/packages/svelte-query-persist-client/package.json index 84bc7b96cc..5dd977f52c 100644 --- a/packages/svelte-query-persist-client/package.json +++ b/packages/svelte-query-persist-client/package.json @@ -14,6 +14,11 @@ "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, + "keywords": [ + "tanstack", + "query", + "svelte" + ], "scripts": { "clean": "premove ./dist ./coverage ./.svelte-kit ./dist-ts", "compile": "tsc --build", @@ -52,12 +57,13 @@ "@tanstack/query-test-utils": "workspace:*", "@tanstack/svelte-query": "workspace:*", "@testing-library/svelte": "^5.2.8", + "@typescript-eslint/parser": "^8.44.1", "eslint-plugin-svelte": "^3.11.0", "svelte": "^5.39.3", "svelte-check": "^4.3.1" }, "peerDependencies": { "@tanstack/svelte-query": "workspace:^", - "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0" + "svelte": "^5.25.0" } } diff --git a/packages/svelte-query-persist-client/src/PersistQueryClientProvider.svelte b/packages/svelte-query-persist-client/src/PersistQueryClientProvider.svelte index b0ba375b04..d94c4fbe20 100644 --- a/packages/svelte-query-persist-client/src/PersistQueryClientProvider.svelte +++ b/packages/svelte-query-persist-client/src/PersistQueryClientProvider.svelte @@ -1,51 +1,56 @@ - - + + {@render children()} diff --git a/packages/svelte-query-persist-client/src/utils.svelte.ts b/packages/svelte-query-persist-client/src/utils.svelte.ts new file mode 100644 index 0000000000..7760eded8c --- /dev/null +++ b/packages/svelte-query-persist-client/src/utils.svelte.ts @@ -0,0 +1,14 @@ +type Box = { current: T } + +export function box(initial: T): Box { + let current = $state(initial) + + return { + get current() { + return current + }, + set current(newValue) { + current = newValue + }, + } +} diff --git a/packages/svelte-query-persist-client/svelte.config.js b/packages/svelte-query-persist-client/svelte.config.js index 94ca454ac7..076d2dcd50 100644 --- a/packages/svelte-query-persist-client/svelte.config.js +++ b/packages/svelte-query-persist-client/svelte.config.js @@ -2,6 +2,9 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' const config = { preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, } export default config diff --git a/packages/svelte-query-persist-client/tests/AwaitOnSuccess/AwaitOnSuccess.svelte b/packages/svelte-query-persist-client/tests/AwaitOnSuccess/AwaitOnSuccess.svelte index 74f09accd8..4835489c1e 100644 --- a/packages/svelte-query-persist-client/tests/AwaitOnSuccess/AwaitOnSuccess.svelte +++ b/packages/svelte-query-persist-client/tests/AwaitOnSuccess/AwaitOnSuccess.svelte @@ -1,20 +1,20 @@ -
{$query.data}
-
fetchStatus: {$query.fetchStatus}
+
{query.data}
+
fetchStatus: {query.fetchStatus}
diff --git a/packages/svelte-query-persist-client/tests/AwaitOnSuccess/Provider.svelte b/packages/svelte-query-persist-client/tests/AwaitOnSuccess/Provider.svelte index c3087ac2fa..79126c724b 100644 --- a/packages/svelte-query-persist-client/tests/AwaitOnSuccess/Provider.svelte +++ b/packages/svelte-query-persist-client/tests/AwaitOnSuccess/Provider.svelte @@ -3,12 +3,16 @@ import AwaitOnSuccess from './AwaitOnSuccess.svelte' import type { OmitKeyof, QueryClient } from '@tanstack/svelte-query' import type { PersistQueryClientOptions } from '@tanstack/query-persist-client-core' - import type { Writable } from 'svelte/store' + import { StatelessRef } from '../utils.svelte' - export let queryClient: QueryClient - export let persistOptions: OmitKeyof - export let onSuccess: () => Promise - export let states: Writable> + interface Props { + queryClient: QueryClient + persistOptions: OmitKeyof + onSuccess: () => Promise + states: StatelessRef> + } + + let { queryClient, persistOptions, onSuccess, states }: Props = $props() diff --git a/packages/svelte-query-persist-client/tests/FreshData/FreshData.svelte b/packages/svelte-query-persist-client/tests/FreshData/FreshData.svelte index 451a2a98f2..fff8ba820d 100644 --- a/packages/svelte-query-persist-client/tests/FreshData/FreshData.svelte +++ b/packages/svelte-query-persist-client/tests/FreshData/FreshData.svelte @@ -1,25 +1,26 @@ -
data: {$query.data ?? 'undefined'}
-
fetchStatus: {$query.fetchStatus}
+
data: {query.data ?? 'undefined'}
+
fetchStatus: {query.fetchStatus}
diff --git a/packages/svelte-query-persist-client/tests/FreshData/Provider.svelte b/packages/svelte-query-persist-client/tests/FreshData/Provider.svelte index 75a9c1aefa..70a9ea483f 100644 --- a/packages/svelte-query-persist-client/tests/FreshData/Provider.svelte +++ b/packages/svelte-query-persist-client/tests/FreshData/Provider.svelte @@ -3,15 +3,17 @@ import FreshData from './FreshData.svelte' import type { OmitKeyof, QueryClient } from '@tanstack/svelte-query' import type { PersistQueryClientOptions } from '@tanstack/query-persist-client-core' - import type { Writable } from 'svelte/store' - import type { StatusResult } from '../utils.js' + import type { StatelessRef, StatusResult } from '../utils.svelte.js' - export let queryClient: QueryClient - export let persistOptions: OmitKeyof - export let states: Writable>> - export let fetched: Writable + interface Props { + queryClient: QueryClient + persistOptions: OmitKeyof + states: StatelessRef>> + } + + let { queryClient, persistOptions, states }: Props = $props() - + diff --git a/packages/svelte-query-persist-client/tests/InitialData/InitialData.svelte b/packages/svelte-query-persist-client/tests/InitialData/InitialData.svelte index 7e661dbe71..a4df9132f8 100644 --- a/packages/svelte-query-persist-client/tests/InitialData/InitialData.svelte +++ b/packages/svelte-query-persist-client/tests/InitialData/InitialData.svelte @@ -1,12 +1,12 @@ -
{$query.data}
-
fetchStatus: {$query.fetchStatus}
+
{query.data}
+
fetchStatus: {query.fetchStatus}
diff --git a/packages/svelte-query-persist-client/tests/InitialData/Provider.svelte b/packages/svelte-query-persist-client/tests/InitialData/Provider.svelte index e5e17f712e..a50338006a 100644 --- a/packages/svelte-query-persist-client/tests/InitialData/Provider.svelte +++ b/packages/svelte-query-persist-client/tests/InitialData/Provider.svelte @@ -3,12 +3,15 @@ import InitialData from './InitialData.svelte' import type { OmitKeyof, QueryClient } from '@tanstack/svelte-query' import type { PersistQueryClientOptions } from '@tanstack/query-persist-client-core' - import type { Writable } from 'svelte/store' - import type { StatusResult } from '../utils.js' + import type { StatelessRef, StatusResult } from '../utils.svelte.js' - export let queryClient: QueryClient - export let persistOptions: OmitKeyof - export let states: Writable>> + interface Props { + queryClient: QueryClient + persistOptions: OmitKeyof + states: StatelessRef>> + } + + let { queryClient, persistOptions, states }: Props = $props() diff --git a/packages/svelte-query-persist-client/tests/OnSuccess/OnSuccess.svelte b/packages/svelte-query-persist-client/tests/OnSuccess/OnSuccess.svelte index 40dd1d90be..a6ef7b3214 100644 --- a/packages/svelte-query-persist-client/tests/OnSuccess/OnSuccess.svelte +++ b/packages/svelte-query-persist-client/tests/OnSuccess/OnSuccess.svelte @@ -1,15 +1,11 @@ -
{$query.data}
-
fetchStatus: {$query.fetchStatus}
+
{query.data}
+
fetchStatus: {query.fetchStatus}
diff --git a/packages/svelte-query-persist-client/tests/OnSuccess/Provider.svelte b/packages/svelte-query-persist-client/tests/OnSuccess/Provider.svelte index c0d2792771..0b280ca570 100644 --- a/packages/svelte-query-persist-client/tests/OnSuccess/Provider.svelte +++ b/packages/svelte-query-persist-client/tests/OnSuccess/Provider.svelte @@ -4,9 +4,13 @@ import type { OmitKeyof, QueryClient } from '@tanstack/svelte-query' import type { PersistQueryClientOptions } from '@tanstack/query-persist-client-core' - export let queryClient: QueryClient - export let persistOptions: OmitKeyof - export let onSuccess: () => void + interface Props { + queryClient: QueryClient + persistOptions: OmitKeyof + onSuccess: () => void + } + + let { queryClient, persistOptions, onSuccess }: Props = $props() diff --git a/packages/svelte-query-persist-client/tests/PersistQueryClientProvider.test.ts b/packages/svelte-query-persist-client/tests/PersistQueryClientProvider.svelte.test.ts similarity index 74% rename from packages/svelte-query-persist-client/tests/PersistQueryClientProvider.test.ts rename to packages/svelte-query-persist-client/tests/PersistQueryClientProvider.svelte.test.ts index a6f3105c63..668a848607 100644 --- a/packages/svelte-query-persist-client/tests/PersistQueryClientProvider.test.ts +++ b/packages/svelte-query-persist-client/tests/PersistQueryClientProvider.svelte.test.ts @@ -1,9 +1,8 @@ import { render, waitFor } from '@testing-library/svelte' import { describe, expect, test, vi } from 'vitest' +import { QueryClient } from '@tanstack/svelte-query' import { persistQueryClientSave } from '@tanstack/query-persist-client-core' -import { get, writable } from 'svelte/store' import { sleep } from '@tanstack/query-test-utils' -import { QueryClient } from '@tanstack/svelte-query' import AwaitOnSuccess from './AwaitOnSuccess/Provider.svelte' import FreshData from './FreshData/Provider.svelte' import OnSuccess from './OnSuccess/Provider.svelte' @@ -11,13 +10,12 @@ import InitialData from './InitialData/Provider.svelte' import RemoveCache from './RemoveCache/Provider.svelte' import RestoreCache from './RestoreCache/Provider.svelte' import UseQueries from './UseQueries/Provider.svelte' - +import { StatelessRef } from './utils.svelte.js' import type { PersistedClient, Persister, } from '@tanstack/query-persist-client-core' -import type { Writable } from 'svelte/store' -import type { StatusResult } from './utils.js' +import type { StatusResult } from './utils.svelte.js' const createMockPersister = (): Persister => { let storedState: PersistedClient | undefined @@ -27,8 +25,7 @@ const createMockPersister = (): Persister => { storedState = persistClient }, async restoreClient() { - await sleep(5) - return storedState + return Promise.resolve(storedState) }, removeClient() { storedState = undefined @@ -47,8 +44,7 @@ const createMockErrorPersister = ( // noop }, async restoreClient() { - await sleep(5) - throw error + return Promise.reject(error) }, removeClient, }, @@ -57,7 +53,7 @@ const createMockErrorPersister = ( describe('PersistQueryClientProvider', () => { test('restores cache from persister', async () => { - const statesStore: Writable>> = writable([]) + const states = new StatelessRef>>([]) const queryClient = new QueryClient() await queryClient.prefetchQuery({ @@ -75,7 +71,7 @@ describe('PersistQueryClientProvider', () => { props: { queryClient, persistOptions: { persister }, - states: statesStore, + states, }, }) @@ -83,34 +79,21 @@ describe('PersistQueryClientProvider', () => { await waitFor(() => rendered.getByText('hydrated')) await waitFor(() => rendered.getByText('fetched')) - const states = get(statesStore) - expect(states).toHaveLength(5) + expect(states.current).toHaveLength(3) - expect(states[0]).toMatchObject({ + expect(states.current[0]).toMatchObject({ status: 'pending', fetchStatus: 'idle', data: undefined, }) - expect(states[1]).toMatchObject({ - status: 'success', - fetchStatus: 'fetching', - data: 'hydrated', - }) - - expect(states[2]).toMatchObject({ - status: 'success', - fetchStatus: 'fetching', - data: 'hydrated', - }) - - expect(states[3]).toMatchObject({ + expect(states.current[1]).toMatchObject({ status: 'success', fetchStatus: 'fetching', data: 'hydrated', }) - expect(states[4]).toMatchObject({ + expect(states.current[2]).toMatchObject({ status: 'success', fetchStatus: 'idle', data: 'fetched', @@ -118,7 +101,7 @@ describe('PersistQueryClientProvider', () => { }) test('should also put useQueries into idle state', async () => { - const statesStore: Writable>> = writable([]) + const states = new StatelessRef>>([]) const queryClient = new QueryClient() await queryClient.prefetchQuery({ @@ -136,7 +119,7 @@ describe('PersistQueryClientProvider', () => { props: { queryClient, persistOptions: { persister }, - states: statesStore, + states, }, }) @@ -144,35 +127,21 @@ describe('PersistQueryClientProvider', () => { await waitFor(() => rendered.getByText('hydrated')) await waitFor(() => rendered.getByText('fetched')) - const states = get(statesStore) + expect(states.current).toHaveLength(3) - expect(states).toHaveLength(5) - - expect(states[0]).toMatchObject({ + expect(states.current[0]).toMatchObject({ status: 'pending', fetchStatus: 'idle', data: undefined, }) - expect(states[1]).toMatchObject({ - status: 'success', - fetchStatus: 'fetching', - data: 'hydrated', - }) - - expect(states[2]).toMatchObject({ + expect(states.current[1]).toMatchObject({ status: 'success', fetchStatus: 'fetching', data: 'hydrated', }) - expect(states[3]).toMatchObject({ - status: 'success', - fetchStatus: 'fetching', - data: 'hydrated', - }) - - expect(states[4]).toMatchObject({ + expect(states.current[2]).toMatchObject({ status: 'success', fetchStatus: 'idle', data: 'fetched', @@ -180,7 +149,7 @@ describe('PersistQueryClientProvider', () => { }) test('should show initialData while restoring', async () => { - const statesStore: Writable>> = writable([]) + const states = new StatelessRef>>([]) const queryClient = new QueryClient() await queryClient.prefetchQuery({ @@ -198,7 +167,7 @@ describe('PersistQueryClientProvider', () => { props: { queryClient, persistOptions: { persister }, - states: statesStore, + states, }, }) @@ -206,34 +175,21 @@ describe('PersistQueryClientProvider', () => { await waitFor(() => rendered.getByText('hydrated')) await waitFor(() => rendered.getByText('fetched')) - const states = get(statesStore) - expect(states).toHaveLength(5) + expect(states.current).toHaveLength(3) - expect(states[0]).toMatchObject({ + expect(states.current[0]).toMatchObject({ status: 'success', fetchStatus: 'idle', data: 'initial', }) - expect(states[1]).toMatchObject({ - status: 'success', - fetchStatus: 'fetching', - data: 'hydrated', - }) - - expect(states[2]).toMatchObject({ + expect(states.current[1]).toMatchObject({ status: 'success', fetchStatus: 'fetching', data: 'hydrated', }) - expect(states[3]).toMatchObject({ - status: 'success', - fetchStatus: 'fetching', - data: 'hydrated', - }) - - expect(states[4]).toMatchObject({ + expect(states.current[2]).toMatchObject({ status: 'success', fetchStatus: 'idle', data: 'fetched', @@ -241,7 +197,7 @@ describe('PersistQueryClientProvider', () => { }) test('should not refetch after restoring when data is fresh', async () => { - const statesStore: Writable>> = writable([]) + const states = new StatelessRef>>([]) const queryClient = new QueryClient() await queryClient.prefetchQuery({ @@ -255,32 +211,31 @@ describe('PersistQueryClientProvider', () => { queryClient.clear() - const fetched = writable(false) - const rendered = render(FreshData, { props: { queryClient, persistOptions: { persister }, - states: statesStore, - fetched, + states, }, }) await waitFor(() => rendered.getByText('data: undefined')) await waitFor(() => rendered.getByText('data: hydrated')) + await expect( + waitFor(() => rendered.getByText('data: fetched'), { + timeout: 100, + }), + ).rejects.toThrowError() - const states = get(statesStore) - expect(states).toHaveLength(2) + expect(states.current).toHaveLength(2) - expect(get(fetched)).toBe(false) - - expect(states[0]).toMatchObject({ + expect(states.current[0]).toMatchObject({ status: 'pending', fetchStatus: 'idle', data: undefined, }) - expect(states[1]).toMatchObject({ + expect(states.current[1]).toMatchObject({ status: 'success', fetchStatus: 'idle', data: 'hydrated', @@ -311,7 +266,6 @@ describe('PersistQueryClientProvider', () => { }) expect(onSuccess).toHaveBeenCalledTimes(0) - await waitFor(() => rendered.getByText('hydrated')) expect(onSuccess).toHaveBeenCalledTimes(1) await waitFor(() => rendered.getByText('fetched')) @@ -330,17 +284,17 @@ describe('PersistQueryClientProvider', () => { queryClient.clear() - const statesStore: Writable> = writable([]) + const states = new StatelessRef>([]) const rendered = render(AwaitOnSuccess, { props: { queryClient, persistOptions: { persister }, - states: statesStore, + states, onSuccess: async () => { - statesStore.update((s) => [...s, 'onSuccess']) - await sleep(20) - statesStore.update((s) => [...s, 'onSuccess done']) + states.current.push('onSuccess') + await sleep(5) + states.current.push('onSuccess done') }, }, }) @@ -348,9 +302,7 @@ describe('PersistQueryClientProvider', () => { await waitFor(() => rendered.getByText('hydrated')) await waitFor(() => rendered.getByText('fetched')) - const states = get(statesStore) - - expect(states).toEqual([ + expect(states.current).toEqual([ 'onSuccess', 'onSuccess done', 'fetching', @@ -359,11 +311,12 @@ describe('PersistQueryClientProvider', () => { }) test('should remove cache after non-successful restoring', async () => { - const consoleMock = vi.spyOn(console, 'error') + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) const consoleWarn = vi .spyOn(console, 'warn') .mockImplementation(() => undefined) - consoleMock.mockImplementation(() => undefined) const queryClient = new QueryClient() const removeClient = vi.fn() diff --git a/packages/svelte-query-persist-client/tests/RemoveCache/Provider.svelte b/packages/svelte-query-persist-client/tests/RemoveCache/Provider.svelte index 7edd6cc903..c6410d912e 100644 --- a/packages/svelte-query-persist-client/tests/RemoveCache/Provider.svelte +++ b/packages/svelte-query-persist-client/tests/RemoveCache/Provider.svelte @@ -4,10 +4,14 @@ import type { OmitKeyof, QueryClient } from '@tanstack/svelte-query' import type { PersistQueryClientOptions } from '@tanstack/query-persist-client-core' - export let queryClient: QueryClient - export let persistOptions: OmitKeyof - export let onSuccess: () => void - export let onError: () => void + interface Props { + queryClient: QueryClient + persistOptions: OmitKeyof + onSuccess: () => void + onError: () => void + } + + let { queryClient, persistOptions, onError, onSuccess }: Props = $props() import { createQuery } from '@tanstack/svelte-query' - import { sleep } from '@tanstack/query-test-utils' - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['test'], - queryFn: async () => { - await sleep(5) - return 'fetched' - }, - }) + queryFn: () => Promise.resolve('fetched'), + })) -
{$query.data}
-
fetchStatus: {$query.fetchStatus}
+
{query.data}
+
fetchStatus: {query.fetchStatus}
diff --git a/packages/svelte-query-persist-client/tests/RestoreCache/Provider.svelte b/packages/svelte-query-persist-client/tests/RestoreCache/Provider.svelte index 531aae8c3f..e89cdbafef 100644 --- a/packages/svelte-query-persist-client/tests/RestoreCache/Provider.svelte +++ b/packages/svelte-query-persist-client/tests/RestoreCache/Provider.svelte @@ -3,12 +3,15 @@ import RestoreCache from './RestoreCache.svelte' import type { OmitKeyof, QueryClient } from '@tanstack/svelte-query' import type { PersistQueryClientOptions } from '@tanstack/query-persist-client-core' - import type { Writable } from 'svelte/store' - import type { StatusResult } from '../utils.js' + import type { StatelessRef, StatusResult } from '../utils.svelte.js' - export let queryClient: QueryClient - export let persistOptions: OmitKeyof - export let states: Writable>> + interface Props { + queryClient: QueryClient + persistOptions: OmitKeyof + states: StatelessRef>> + } + + let { queryClient, persistOptions, states }: Props = $props() diff --git a/packages/svelte-query-persist-client/tests/RestoreCache/RestoreCache.svelte b/packages/svelte-query-persist-client/tests/RestoreCache/RestoreCache.svelte index de78c98bfd..79b9b6add5 100644 --- a/packages/svelte-query-persist-client/tests/RestoreCache/RestoreCache.svelte +++ b/packages/svelte-query-persist-client/tests/RestoreCache/RestoreCache.svelte @@ -1,21 +1,21 @@ -
{$query.data}
-
fetchStatus: {$query.fetchStatus}
+
{query.data}
+
fetchStatus: {query.fetchStatus}
diff --git a/packages/svelte-query-persist-client/tests/UseQueries/Provider.svelte b/packages/svelte-query-persist-client/tests/UseQueries/Provider.svelte index 56429b7d81..b5a3857bf7 100644 --- a/packages/svelte-query-persist-client/tests/UseQueries/Provider.svelte +++ b/packages/svelte-query-persist-client/tests/UseQueries/Provider.svelte @@ -3,12 +3,15 @@ import UseQueries from './UseQueries.svelte' import type { OmitKeyof, QueryClient } from '@tanstack/svelte-query' import type { PersistQueryClientOptions } from '@tanstack/query-persist-client-core' - import type { Writable } from 'svelte/store' - import type { StatusResult } from '../utils.js' + import type { StatelessRef, StatusResult } from '../utils.svelte.js' - export let queryClient: QueryClient - export let persistOptions: OmitKeyof - export let states: Writable>> + interface Props { + queryClient: QueryClient + persistOptions: OmitKeyof + states: StatelessRef>> + } + + let { queryClient, persistOptions, states }: Props = $props() diff --git a/packages/svelte-query-persist-client/tests/UseQueries/UseQueries.svelte b/packages/svelte-query-persist-client/tests/UseQueries/UseQueries.svelte index 15b5ab46ff..4d646ac8cf 100644 --- a/packages/svelte-query-persist-client/tests/UseQueries/UseQueries.svelte +++ b/packages/svelte-query-persist-client/tests/UseQueries/UseQueries.svelte @@ -1,25 +1,25 @@ -
{$queries[0].data}
-
fetchStatus: {$queries[0].fetchStatus}
+
{queries[0].data}
+
fetchStatus: {queries[0].fetchStatus}
diff --git a/packages/svelte-query-persist-client/tests/utils.ts b/packages/svelte-query-persist-client/tests/utils.svelte.ts similarity index 50% rename from packages/svelte-query-persist-client/tests/utils.ts rename to packages/svelte-query-persist-client/tests/utils.svelte.ts index bf757da058..47162efdea 100644 --- a/packages/svelte-query-persist-client/tests/utils.ts +++ b/packages/svelte-query-persist-client/tests/utils.svelte.ts @@ -3,3 +3,10 @@ export type StatusResult = { fetchStatus: string data: T | undefined } + +export class StatelessRef { + current: T + constructor(value: T) { + this.current = value + } +} diff --git a/packages/svelte-query-persist-client/vite.config.ts b/packages/svelte-query-persist-client/vite.config.ts index 1249fcc5a8..fd7a429728 100644 --- a/packages/svelte-query-persist-client/vite.config.ts +++ b/packages/svelte-query-persist-client/vite.config.ts @@ -23,7 +23,6 @@ export default defineConfig({ watch: false, environment: 'jsdom', setupFiles: ['./tests/test-setup.ts'], - coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, typecheck: { enabled: true }, restoreMocks: true, }, diff --git a/packages/svelte-query/eslint.config.js b/packages/svelte-query/eslint.config.js index b657d69d66..e102c19ee9 100644 --- a/packages/svelte-query/eslint.config.js +++ b/packages/svelte-query/eslint.config.js @@ -1,17 +1,19 @@ // @ts-check +import tsParser from '@typescript-eslint/parser' import pluginSvelte from 'eslint-plugin-svelte' import rootConfig from './root.eslint.config.js' import svelteConfig from './svelte.config.js' export default [ ...rootConfig, - ...pluginSvelte.configs['flat/recommended'], + ...pluginSvelte.configs['recommended'], { - files: ['**/*.svelte'], + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], languageOptions: { parserOptions: { - parser: '@typescript-eslint/parser', + parser: tsParser, + extraFileExtensions: ['.svelte'], svelteConfig, }, }, diff --git a/packages/svelte-query/package.json b/packages/svelte-query/package.json index 1a6d8b8755..e6b2027bbe 100644 --- a/packages/svelte-query/package.json +++ b/packages/svelte-query/package.json @@ -14,6 +14,11 @@ "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, + "keywords": [ + "tanstack", + "query", + "svelte" + ], "scripts": { "clean": "premove ./dist ./coverage ./.svelte-kit ./dist-ts", "compile": "tsc --build", @@ -51,11 +56,12 @@ "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tanstack/query-test-utils": "workspace:*", "@testing-library/svelte": "^5.2.8", + "@typescript-eslint/parser": "^8.44.1", "eslint-plugin-svelte": "^3.11.0", "svelte": "^5.39.3", "svelte-check": "^4.3.1" }, "peerDependencies": { - "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0" + "svelte": "^5.25.0" } } diff --git a/packages/svelte-query/src/HydrationBoundary.svelte b/packages/svelte-query/src/HydrationBoundary.svelte index 330984311f..96c26068a2 100644 --- a/packages/svelte-query/src/HydrationBoundary.svelte +++ b/packages/svelte-query/src/HydrationBoundary.svelte @@ -1,16 +1,27 @@ - +{@render children()} diff --git a/packages/svelte-query/src/QueryClientProvider.svelte b/packages/svelte-query/src/QueryClientProvider.svelte index 9f43e49093..36f1df43b1 100644 --- a/packages/svelte-query/src/QueryClientProvider.svelte +++ b/packages/svelte-query/src/QueryClientProvider.svelte @@ -2,8 +2,10 @@ import { onDestroy, onMount } from 'svelte' import { QueryClient } from '@tanstack/query-core' import { setQueryClientContext } from './context.js' + import type { QueryClientProviderProps } from './types.js' - export let client = new QueryClient() + const { client = new QueryClient(), children }: QueryClientProviderProps = + $props() onMount(() => { client.mount() @@ -16,4 +18,4 @@ }) - +{@render children()} diff --git a/packages/svelte-query/src/containers.svelte.ts b/packages/svelte-query/src/containers.svelte.ts new file mode 100644 index 0000000000..60d27c6843 --- /dev/null +++ b/packages/svelte-query/src/containers.svelte.ts @@ -0,0 +1,123 @@ +import { SvelteSet, createSubscriber } from 'svelte/reactivity' + +type VoidFn = () => void +type Subscriber = (update: VoidFn) => void | VoidFn + +export type Box = { current: T } + +export class ReactiveValue implements Box { + #fn + #subscribe + + constructor(fn: () => T, onSubscribe: Subscriber) { + this.#fn = fn + this.#subscribe = createSubscriber((update) => onSubscribe(update)) + } + + get current() { + this.#subscribe() + return this.#fn() + } +} + +/** + * Makes all of the top-level keys of an object into $state.raw fields whose initial values + * are the same as in the original object. Does not mutate the original object. Provides an `update` + * function that _can_ (but does not have to be) be used to replace all of the object's top-level keys + * with the values of the new object, while maintaining the original root object's reference. + */ +export function createRawRef>( + init: T, +): [T, (newValue: T) => void] { + const refObj = (Array.isArray(init) ? [] : {}) as T + const hiddenKeys = new SvelteSet() + const out = new Proxy(refObj, { + set(target, prop, value, receiver) { + hiddenKeys.delete(prop) + if (prop in target) { + return Reflect.set(target, prop, value, receiver) + } + let state = $state.raw(value) + Object.defineProperty(target, prop, { + configurable: true, + enumerable: true, + get: () => { + // If this is a lazy value, we need to call it. + // We can't do something like typeof state === 'function' + // because the value could actually be a function that we don't want to call. + return state && isBranded(state) ? state() : state + }, + set: (v) => { + state = v + }, + }) + return true + }, + has: (target, prop) => { + if (hiddenKeys.has(prop)) { + return false + } + return prop in target + }, + ownKeys(target) { + return Reflect.ownKeys(target).filter((key) => !hiddenKeys.has(key)) + }, + getOwnPropertyDescriptor(target, prop) { + if (hiddenKeys.has(prop)) { + return undefined + } + return Reflect.getOwnPropertyDescriptor(target, prop) + }, + deleteProperty(target, prop) { + if (prop in target) { + // @ts-expect-error + // We need to set the value to undefined to signal to the listeners that the value has changed. + // If we just deleted it, the reactivity system wouldn't have any idea that the value was gone. + target[prop] = undefined + hiddenKeys.add(prop) + if (Array.isArray(target)) { + target.length-- + } + return true + } + return false + }, + }) + + function update(newValue: T) { + const existingKeys = Object.keys(out) + const newKeys = Object.keys(newValue) + const keysToRemove = existingKeys.filter((key) => !newKeys.includes(key)) + for (const key of keysToRemove) { + // @ts-expect-error + delete out[key] + } + for (const key of newKeys) { + // @ts-expect-error + // This craziness is required because Tanstack Query defines getters for all of the keys on the object. + // These getters track property access, so if we access all of them here, we'll end up tracking everything. + // So we wrap the property access in a special function that we can identify later to lazily access the value. + // (See above) + out[key] = brand(() => newValue[key]) + } + } + + // we can't pass `init` directly into the proxy because it'll never set the state fields + // (because (prop in target) will always be true) + update(init) + + return [out, update] +} + +const lazyBrand = Symbol('LazyValue') +type Branded unknown> = T & { [lazyBrand]: true } + +function brand unknown>(fn: T): Branded { + // @ts-expect-error + fn[lazyBrand] = true + return fn as Branded +} + +function isBranded unknown>(fn: T): fn is Branded { + return Boolean((fn as Branded)[lazyBrand]) +} diff --git a/packages/svelte-query/src/context.ts b/packages/svelte-query/src/context.ts index 962451b232..27595517f5 100644 --- a/packages/svelte-query/src/context.ts +++ b/packages/svelte-query/src/context.ts @@ -1,20 +1,19 @@ import { getContext, setContext } from 'svelte' -import { readable } from 'svelte/store' import type { QueryClient } from '@tanstack/query-core' -import type { Readable } from 'svelte/store' +import type { Box } from './containers.svelte' -const _contextKey = '$$_queryClient' +const _contextKey = Symbol('QueryClient') /** Retrieves a Client from Svelte's context */ export const getQueryClientContext = (): QueryClient => { - const client = getContext(_contextKey) + const client = getContext(_contextKey) if (!client) { throw new Error( 'No QueryClient was found in Svelte context. Did you forget to wrap your component with QueryClientProvider?', ) } - return client as QueryClient + return client } /** Sets a QueryClient on Svelte's context */ @@ -22,21 +21,21 @@ export const setQueryClientContext = (client: QueryClient): void => { setContext(_contextKey, client) } -const _isRestoringContextKey = '$$_isRestoring' +const _isRestoringContextKey = Symbol('isRestoring') /** Retrieves a `isRestoring` from Svelte's context */ -export const getIsRestoringContext = (): Readable => { +export const getIsRestoringContext = (): Box => { try { - const isRestoring = getContext | undefined>( + const isRestoring = getContext | undefined>( _isRestoringContextKey, ) - return isRestoring ? isRestoring : readable(false) + return isRestoring ?? { current: false } } catch (error) { - return readable(false) + return { current: false } } } /** Sets a `isRestoring` on Svelte's context */ -export const setIsRestoringContext = (isRestoring: Readable): void => { +export const setIsRestoringContext = (isRestoring: Box): void => { setContext(_isRestoringContextKey, isRestoring) } diff --git a/packages/svelte-query/src/createBaseQuery.svelte.ts b/packages/svelte-query/src/createBaseQuery.svelte.ts new file mode 100644 index 0000000000..03fc6b28db --- /dev/null +++ b/packages/svelte-query/src/createBaseQuery.svelte.ts @@ -0,0 +1,107 @@ +import { useIsRestoring } from './useIsRestoring.js' +import { useQueryClient } from './useQueryClient.js' +import { createRawRef } from './containers.svelte.js' +import { watchChanges } from './utils.svelte.js' +import type { QueryClient, QueryKey, QueryObserver } from '@tanstack/query-core' +import type { + Accessor, + CreateBaseQueryOptions, + CreateBaseQueryResult, +} from './types.js' + +/** + * Base implementation for `createQuery` and `createInfiniteQuery` + * @param options - A function that returns query options + * @param Observer - The observer from query-core + * @param queryClient - Custom query client which overrides provider + */ +export function createBaseQuery< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, +>( + options: Accessor< + CreateBaseQueryOptions + >, + Observer: typeof QueryObserver, + queryClient?: Accessor, +): CreateBaseQueryResult { + /** Load query client */ + const client = $derived(useQueryClient(queryClient?.())) + const isRestoring = useIsRestoring() + + const resolvedOptions = $derived.by(() => { + const opts = client.defaultQueryOptions(options()) + opts._optimisticResults = isRestoring.current ? 'isRestoring' : 'optimistic' + return opts + }) + + /** Creates the observer */ + // svelte-ignore state_referenced_locally - intentional, initial value + let observer = $state( + new Observer( + client, + resolvedOptions, + ), + ) + watchChanges( + () => client, + 'pre', + () => { + observer = new Observer< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >(client, resolvedOptions) + }, + ) + + function createResult() { + const result = observer.getOptimisticResult(resolvedOptions) + return !resolvedOptions.notifyOnChangeProps + ? observer.trackResult(result) + : result + } + const [query, update] = createRawRef( + // svelte-ignore state_referenced_locally - intentional, initial value + createResult(), + ) + + $effect(() => { + const unsubscribe = isRestoring.current + ? () => undefined + : observer.subscribe(() => update(createResult())) + observer.updateResult() + return unsubscribe + }) + + watchChanges( + () => resolvedOptions, + 'pre', + () => { + observer.setOptions(resolvedOptions) + }, + ) + watchChanges( + () => [resolvedOptions, observer], + 'pre', + () => { + // The only reason this is necessary is because of `isRestoring`. + // Because we don't subscribe while restoring, the following can occur: + // - `isRestoring` is true + // - `isRestoring` becomes false + // - `observer.subscribe` and `observer.updateResult` is called in the above effect, + // but the subsequent `fetch` has already completed + // - `result` misses the intermediate restored-but-not-fetched state + // + // this could technically be its own effect but that doesn't seem necessary + update(createResult()) + }, + ) + + return query +} diff --git a/packages/svelte-query/src/createBaseQuery.ts b/packages/svelte-query/src/createBaseQuery.ts deleted file mode 100644 index df45764bca..0000000000 --- a/packages/svelte-query/src/createBaseQuery.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { derived, get, readable } from 'svelte/store' -import { noop, notifyManager } from '@tanstack/query-core' -import { useIsRestoring } from './useIsRestoring.js' -import { useQueryClient } from './useQueryClient.js' -import { isSvelteStore } from './utils.js' -import type { - QueryClient, - QueryKey, - QueryObserver, - QueryObserverResult, -} from '@tanstack/query-core' -import type { - CreateBaseQueryOptions, - CreateBaseQueryResult, - StoreOrVal, -} from './types.js' - -export function createBaseQuery< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey extends QueryKey, ->( - options: StoreOrVal< - CreateBaseQueryOptions - >, - Observer: typeof QueryObserver, - queryClient?: QueryClient, -): CreateBaseQueryResult { - /** Load query client */ - const client = useQueryClient(queryClient) - const isRestoring = useIsRestoring() - /** Converts options to a svelte store if not already a store object */ - const optionsStore = isSvelteStore(options) ? options : readable(options) - - /** Creates a store that has the default options applied */ - const defaultedOptionsStore = derived( - [optionsStore, isRestoring], - ([$optionsStore, $isRestoring]) => { - const defaultedOptions = client.defaultQueryOptions($optionsStore) - defaultedOptions._optimisticResults = $isRestoring - ? 'isRestoring' - : 'optimistic' - return defaultedOptions - }, - ) - - /** Creates the observer */ - const observer = new Observer< - TQueryFnData, - TError, - TData, - TQueryData, - TQueryKey - >(client, get(defaultedOptionsStore)) - - defaultedOptionsStore.subscribe(($defaultedOptions) => { - observer.setOptions($defaultedOptions) - }) - - const result = derived< - typeof isRestoring, - QueryObserverResult - >(isRestoring, ($isRestoring, set) => { - const unsubscribe = $isRestoring - ? noop - : observer.subscribe(notifyManager.batchCalls(set)) - observer.updateResult() - return unsubscribe - }) - - /** Subscribe to changes in result and defaultedOptionsStore */ - const { subscribe } = derived( - [result, defaultedOptionsStore], - ([$result, $defaultedOptionsStore]) => { - $result = observer.getOptimisticResult($defaultedOptionsStore) - return !$defaultedOptionsStore.notifyOnChangeProps - ? observer.trackResult($result) - : $result - }, - ) - - return { subscribe } -} diff --git a/packages/svelte-query/src/createInfiniteQuery.ts b/packages/svelte-query/src/createInfiniteQuery.ts index 5ebb82213a..e8fe794890 100644 --- a/packages/svelte-query/src/createInfiniteQuery.ts +++ b/packages/svelte-query/src/createInfiniteQuery.ts @@ -1,5 +1,5 @@ import { InfiniteQueryObserver } from '@tanstack/query-core' -import { createBaseQuery } from './createBaseQuery.js' +import { createBaseQuery } from './createBaseQuery.svelte.js' import type { DefaultError, InfiniteData, @@ -8,9 +8,9 @@ import type { QueryObserver, } from '@tanstack/query-core' import type { + Accessor, CreateInfiniteQueryOptions, CreateInfiniteQueryResult, - StoreOrVal, } from './types.js' export function createInfiniteQuery< @@ -20,7 +20,7 @@ export function createInfiniteQuery< TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, >( - options: StoreOrVal< + options: Accessor< CreateInfiniteQueryOptions< TQueryFnData, TError, @@ -29,7 +29,7 @@ export function createInfiniteQuery< TPageParam > >, - queryClient?: QueryClient, + queryClient?: Accessor, ): CreateInfiniteQueryResult { return createBaseQuery( options, diff --git a/packages/svelte-query/src/createMutation.svelte.ts b/packages/svelte-query/src/createMutation.svelte.ts new file mode 100644 index 0000000000..51ff74827a --- /dev/null +++ b/packages/svelte-query/src/createMutation.svelte.ts @@ -0,0 +1,91 @@ +import { MutationObserver, noop, notifyManager } from '@tanstack/query-core' +import { useQueryClient } from './useQueryClient.js' +import { watchChanges } from './utils.svelte.js' +import type { + Accessor, + CreateMutateFunction, + CreateMutationOptions, + CreateMutationResult, +} from './types.js' + +import type { DefaultError, QueryClient } from '@tanstack/query-core' + +/** + * @param options - A function that returns mutation options + * @param queryClient - Custom query client which overrides provider + */ +export function createMutation< + TData = unknown, + TError = DefaultError, + TVariables = void, + TContext = unknown, +>( + options: Accessor>, + queryClient?: Accessor, +): CreateMutationResult { + const client = $derived(useQueryClient(queryClient?.())) + + // svelte-ignore state_referenced_locally - intentional, initial value + let observer = $state( + // svelte-ignore state_referenced_locally - intentional, initial value + new MutationObserver( + client, + options(), + ), + ) + + watchChanges( + () => client, + 'pre', + () => { + observer = new MutationObserver(client, options()) + }, + ) + + $effect.pre(() => { + observer.setOptions(options()) + }) + + const mutate = >(( + variables, + mutateOptions, + ) => { + observer.mutate(variables, mutateOptions).catch(noop) + }) + + let result = $state(observer.getCurrentResult()) + watchChanges( + () => observer, + 'pre', + () => { + result = observer.getCurrentResult() + }, + ) + + $effect.pre(() => { + const unsubscribe = observer.subscribe((val) => { + notifyManager.batchCalls(() => { + Object.assign(result, val) + })() + }) + return unsubscribe + }) + + const resultProxy = $derived( + new Proxy(result, { + get: (_, prop) => { + const r = { + ...result, + mutate, + mutateAsync: result.mutate, + } + if (prop == 'value') return r + // @ts-expect-error + return r[prop] + }, + }), + ) + + // @ts-expect-error + return resultProxy +} diff --git a/packages/svelte-query/src/createMutation.ts b/packages/svelte-query/src/createMutation.ts deleted file mode 100644 index 698ea75ac7..0000000000 --- a/packages/svelte-query/src/createMutation.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { derived, get, readable } from 'svelte/store' -import { MutationObserver, noop, notifyManager } from '@tanstack/query-core' -import { useQueryClient } from './useQueryClient.js' -import { isSvelteStore } from './utils.js' -import type { - CreateMutateFunction, - CreateMutationOptions, - CreateMutationResult, - StoreOrVal, -} from './types.js' -import type { DefaultError, QueryClient } from '@tanstack/query-core' - -export function createMutation< - TData = unknown, - TError = DefaultError, - TVariables = void, - TOnMutateResult = unknown, ->( - options: StoreOrVal< - CreateMutationOptions - >, - queryClient?: QueryClient, -): CreateMutationResult { - const client = useQueryClient(queryClient) - - const optionsStore = isSvelteStore(options) ? options : readable(options) - - const observer = new MutationObserver< - TData, - TError, - TVariables, - TOnMutateResult - >(client, get(optionsStore)) - let mutate: CreateMutateFunction - - optionsStore.subscribe(($options) => { - mutate = (variables, mutateOptions) => { - observer.mutate(variables, mutateOptions).catch(noop) - } - observer.setOptions($options) - }) - - const result = readable(observer.getCurrentResult(), (set) => { - return observer.subscribe(notifyManager.batchCalls((val) => set(val))) - }) - - const { subscribe } = derived(result, ($result) => ({ - ...$result, - mutate, - mutateAsync: $result.mutate, - })) - - return { subscribe } -} diff --git a/packages/svelte-query/src/createQueries.ts b/packages/svelte-query/src/createQueries.svelte.ts similarity index 59% rename from packages/svelte-query/src/createQueries.ts rename to packages/svelte-query/src/createQueries.svelte.ts index 25e9731c10..dec5756129 100644 --- a/packages/svelte-query/src/createQueries.ts +++ b/packages/svelte-query/src/createQueries.svelte.ts @@ -1,33 +1,33 @@ -import { QueriesObserver, noop, notifyManager } from '@tanstack/query-core' -import { derived, get, readable } from 'svelte/store' +import { QueriesObserver } from '@tanstack/query-core' import { useIsRestoring } from './useIsRestoring.js' +import { createRawRef } from './containers.svelte.js' import { useQueryClient } from './useQueryClient.js' -import { isSvelteStore } from './utils.js' -import type { Readable } from 'svelte/store' -import type { StoreOrVal } from './types.js' +import type { + Accessor, + CreateQueryOptions, + CreateQueryResult, + DefinedCreateQueryResult, +} from './types.js' import type { DefaultError, - DefinedQueryObserverResult, OmitKeyof, QueriesObserverOptions, QueriesPlaceholderDataFunction, QueryClient, QueryFunction, QueryKey, - QueryObserverOptions, - QueryObserverResult, ThrowOnError, } from '@tanstack/query-core' // This defines the `CreateQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`. // `placeholderData` function always gets undefined passed -type QueryObserverOptionsForCreateQueries< +type CreateQueryOptionsForCreateQueries< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = OmitKeyof< - QueryObserverOptions, + CreateQueryOptions, 'placeholderData' > & { placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction @@ -37,60 +37,60 @@ type QueryObserverOptionsForCreateQueries< type MAXIMUM_DEPTH = 20 // Widen the type of the symbol to enable type inference even if skipToken is not immutable. -type SkipTokenForUseQueries = symbol +type SkipTokenForCreateQueries = symbol -type GetQueryObserverOptionsForCreateQueries = +type GetCreateQueryOptionsForCreateQueries = // Part 1: responsible for applying explicit type parameter to function arguments, if object { queryFnData: TQueryFnData, error: TError, data: TData } T extends { queryFnData: infer TQueryFnData error?: infer TError data: infer TData } - ? QueryObserverOptionsForCreateQueries + ? CreateQueryOptionsForCreateQueries : T extends { queryFnData: infer TQueryFnData; error?: infer TError } - ? QueryObserverOptionsForCreateQueries + ? CreateQueryOptionsForCreateQueries : T extends { data: infer TData; error?: infer TError } - ? QueryObserverOptionsForCreateQueries + ? CreateQueryOptionsForCreateQueries : // Part 2: responsible for applying explicit type parameter to function arguments, if tuple [TQueryFnData, TError, TData] T extends [infer TQueryFnData, infer TError, infer TData] - ? QueryObserverOptionsForCreateQueries + ? CreateQueryOptionsForCreateQueries : T extends [infer TQueryFnData, infer TError] - ? QueryObserverOptionsForCreateQueries + ? CreateQueryOptionsForCreateQueries : T extends [infer TQueryFnData] - ? QueryObserverOptionsForCreateQueries + ? CreateQueryOptionsForCreateQueries : // Part 3: responsible for inferring and enforcing type if no explicit parameter was provided T extends { queryFn?: | QueryFunction - | SkipTokenForUseQueries + | SkipTokenForCreateQueries select?: (data: any) => infer TData throwOnError?: ThrowOnError } - ? QueryObserverOptionsForCreateQueries< + ? CreateQueryOptionsForCreateQueries< TQueryFnData, unknown extends TError ? DefaultError : TError, unknown extends TData ? TQueryFnData : TData, TQueryKey > : // Fallback - QueryObserverOptionsForCreateQueries + CreateQueryOptionsForCreateQueries -// A defined initialData setting should return a DefinedQueryObserverResult rather than CreateQueryResult +// A defined initialData setting should return a DefinedCreateQueryResult rather than CreateQueryResult type GetDefinedOrUndefinedQueryResult = T extends { initialData?: infer TInitialData } ? unknown extends TInitialData - ? QueryObserverResult + ? CreateQueryResult : TInitialData extends TData - ? DefinedQueryObserverResult + ? DefinedCreateQueryResult : TInitialData extends () => infer TInitialDataResult ? unknown extends TInitialDataResult - ? QueryObserverResult + ? CreateQueryResult : TInitialDataResult extends TData - ? DefinedQueryObserverResult - : QueryObserverResult - : QueryObserverResult - : QueryObserverResult + ? DefinedCreateQueryResult + : CreateQueryResult + : CreateQueryResult + : CreateQueryResult type GetCreateQueryResult = // Part 1: responsible for mapping explicit type parameter to function result, if object @@ -111,7 +111,7 @@ type GetCreateQueryResult = T extends { queryFn?: | QueryFunction - | SkipTokenForUseQueries + | SkipTokenForCreateQueries select?: (data: any) => infer TData throwOnError?: ThrowOnError } @@ -121,7 +121,7 @@ type GetCreateQueryResult = unknown extends TError ? DefaultError : TError > : // Fallback - QueryObserverResult + CreateQueryResult /** * QueriesOptions reducer recursively unwraps function arguments to infer/enforce type param @@ -131,15 +131,15 @@ export type QueriesOptions< TResults extends Array = [], TDepth extends ReadonlyArray = [], > = TDepth['length'] extends MAXIMUM_DEPTH - ? Array + ? Array : T extends [] ? [] : T extends [infer Head] - ? [...TResults, GetQueryObserverOptionsForCreateQueries] + ? [...TResults, GetCreateQueryOptionsForCreateQueries] : T extends [infer Head, ...infer Tails] ? QueriesOptions< [...Tails], - [...TResults, GetQueryObserverOptionsForCreateQueries], + [...TResults, GetCreateQueryOptionsForCreateQueries], [...TDepth, 1] > : ReadonlyArray extends T @@ -147,7 +147,7 @@ export type QueriesOptions< : // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogenous type! // use this to infer the param types in the case of Array.map() argument T extends Array< - QueryObserverOptionsForCreateQueries< + CreateQueryOptionsForCreateQueries< infer TQueryFnData, infer TError, infer TData, @@ -155,7 +155,7 @@ export type QueriesOptions< > > ? Array< - QueryObserverOptionsForCreateQueries< + CreateQueryOptionsForCreateQueries< TQueryFnData, TError, TData, @@ -163,7 +163,7 @@ export type QueriesOptions< > > : // Fallback - Array + Array /** * QueriesResults reducer recursively maps type param to results @@ -173,7 +173,7 @@ export type QueriesResults< TResults extends Array = [], TDepth extends ReadonlyArray = [], > = TDepth['length'] extends MAXIMUM_DEPTH - ? Array + ? Array : T extends [] ? [] : T extends [infer Head] @@ -190,75 +190,65 @@ export function createQueries< T extends Array, TCombinedResult = QueriesResults, >( - { - queries, - ...options - }: { + createQueriesOptions: Accessor<{ queries: - | StoreOrVal<[...QueriesOptions]> - | StoreOrVal< - [...{ [K in keyof T]: GetQueryObserverOptionsForCreateQueries }] - > + | readonly [...QueriesOptions] + | readonly [ + ...{ [K in keyof T]: GetCreateQueryOptionsForCreateQueries }, + ] combine?: (result: QueriesResults) => TCombinedResult - }, - queryClient?: QueryClient, -): Readable { - const client = useQueryClient(queryClient) + }>, + queryClient?: Accessor, +): TCombinedResult { + const client = $derived(useQueryClient(queryClient?.())) const isRestoring = useIsRestoring() - const queriesStore = isSvelteStore(queries) ? queries : readable(queries) - - const defaultedQueriesStore = derived( - [queriesStore, isRestoring], - ([$queries, $isRestoring]) => { - return $queries.map((opts) => { - const defaultedOptions = client.defaultQueryOptions( - opts as QueryObserverOptions, - ) - // Make sure the results are already in fetching state before subscribing or updating options - defaultedOptions._optimisticResults = $isRestoring - ? 'isRestoring' - : 'optimistic' - return defaultedOptions - }) - }, + const { queries, combine } = $derived.by(createQueriesOptions) + const resolvedQueryOptions = $derived( + queries.map((opts) => { + const resolvedOptions = client.defaultQueryOptions(opts) + // Make sure the results are already in fetching state before subscribing or updating options + resolvedOptions._optimisticResults = isRestoring.current + ? 'isRestoring' + : 'optimistic' + return resolvedOptions + }), ) - const observer = new QueriesObserver( - client, - get(defaultedQueriesStore), - options as QueriesObserverOptions, + + // can't do same as createMutation, as QueriesObserver has no `setOptions` method + const observer = $derived( + new QueriesObserver( + client, + resolvedQueryOptions, + combine as QueriesObserverOptions, + ), ) - defaultedQueriesStore.subscribe(($defaultedQueries) => { - // Do not notify on updates because of changes in the options because - // these changes should already be reflected in the optimistic result. - observer.setQueries( - $defaultedQueries, - options as QueriesObserverOptions, + function createResult() { + const [_, getCombinedResult, trackResult] = observer.getOptimisticResult( + resolvedQueryOptions, + combine as QueriesObserverOptions['combine'], ) - }) + return getCombinedResult(trackResult()) + } - const result = derived([isRestoring], ([$isRestoring], set) => { - const unsubscribe = $isRestoring - ? noop - : observer.subscribe(notifyManager.batchCalls(set)) + // @ts-expect-error - the crazy-complex TCombinedResult type doesn't like being called an array + // svelte-ignore state_referenced_locally + const [results, update] = createRawRef(createResult()) - return () => unsubscribe() + $effect(() => { + const unsubscribe = isRestoring.current + ? () => undefined + : observer.subscribe(() => update(createResult())) + return unsubscribe }) - const { subscribe } = derived( - [result, defaultedQueriesStore], - // @ts-expect-error svelte-check thinks this is unused - ([$result, $defaultedQueriesStore]) => { - const [rawResult, combineResult, trackResult] = - observer.getOptimisticResult( - $defaultedQueriesStore, - (options as QueriesObserverOptions).combine, - ) - $result = rawResult - return combineResult(trackResult()) - }, - ) + $effect.pre(() => { + observer.setQueries(resolvedQueryOptions, { + combine, + } as QueriesObserverOptions) + update(createResult()) + }) - return { subscribe } + return results } diff --git a/packages/svelte-query/src/createQuery.ts b/packages/svelte-query/src/createQuery.ts index 51a43c274c..bf7efe81a7 100644 --- a/packages/svelte-query/src/createQuery.ts +++ b/packages/svelte-query/src/createQuery.ts @@ -1,11 +1,11 @@ import { QueryObserver } from '@tanstack/query-core' -import { createBaseQuery } from './createBaseQuery.js' +import { createBaseQuery } from './createBaseQuery.svelte.js' import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core' import type { + Accessor, CreateQueryOptions, CreateQueryResult, DefinedCreateQueryResult, - StoreOrVal, } from './types.js' import type { DefinedInitialDataOptions, @@ -18,11 +18,11 @@ export function createQuery< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - options: StoreOrVal< - DefinedInitialDataOptions + options: Accessor< + UndefinedInitialDataOptions >, - queryClient?: QueryClient, -): DefinedCreateQueryResult + queryClient?: Accessor, +): CreateQueryResult export function createQuery< TQueryFnData = unknown, @@ -30,27 +30,25 @@ export function createQuery< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - options: StoreOrVal< - UndefinedInitialDataOptions + options: Accessor< + DefinedInitialDataOptions >, - queryClient?: QueryClient, -): CreateQueryResult + queryClient?: Accessor, +): DefinedCreateQueryResult export function createQuery< - TQueryFnData = unknown, + TQueryFnData, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - options: StoreOrVal< - CreateQueryOptions - >, - queryClient?: QueryClient, + options: Accessor>, + queryClient?: Accessor, ): CreateQueryResult export function createQuery( - options: StoreOrVal, - queryClient?: QueryClient, + options: Accessor, + queryClient?: Accessor, ) { return createBaseQuery(options, QueryObserver, queryClient) } diff --git a/packages/svelte-query/src/index.ts b/packages/svelte-query/src/index.ts index 735cd89f59..1b74a9be91 100644 --- a/packages/svelte-query/src/index.ts +++ b/packages/svelte-query/src/index.ts @@ -8,20 +8,20 @@ export * from './types.js' export * from './context.js' export { createQuery } from './createQuery.js' -export type { QueriesResults, QueriesOptions } from './createQueries.js' +export type { QueriesResults, QueriesOptions } from './createQueries.svelte.js' export type { DefinedInitialDataOptions, UndefinedInitialDataOptions, } from './queryOptions.js' export { queryOptions } from './queryOptions.js' -export { createQueries } from './createQueries.js' +export { createQueries } from './createQueries.svelte.js' export { createInfiniteQuery } from './createInfiniteQuery.js' export { infiniteQueryOptions } from './infiniteQueryOptions.js' -export { createMutation } from './createMutation.js' -export { useMutationState } from './useMutationState.js' +export { createMutation } from './createMutation.svelte.js' +export { useMutationState } from './useMutationState.svelte.js' export { useQueryClient } from './useQueryClient.js' -export { useIsFetching } from './useIsFetching.js' -export { useIsMutating } from './useIsMutating.js' +export { useIsFetching } from './useIsFetching.svelte.js' +export { useIsMutating } from './useIsMutating.svelte.js' export { useIsRestoring } from './useIsRestoring.js' export { useHydrate } from './useHydrate.js' export { default as HydrationBoundary } from './HydrationBoundary.svelte' diff --git a/packages/svelte-query/src/types.ts b/packages/svelte-query/src/types.ts index 7a25a15bce..9a64d10dc3 100644 --- a/packages/svelte-query/src/types.ts +++ b/packages/svelte-query/src/types.ts @@ -1,3 +1,4 @@ +import type { Snippet } from 'svelte' import type { DefaultError, DefinedQueryObserverResult, @@ -11,14 +12,13 @@ import type { MutationState, OmitKeyof, Override, + QueryClient, QueryKey, QueryObserverOptions, QueryObserverResult, } from '@tanstack/query-core' -import type { Readable } from 'svelte/store' -/** Allows a type to be either the base object or a store of that object */ -export type StoreOrVal = T | Readable +export type Accessor = () => T /** Options for createBaseQuery */ export type CreateBaseQueryOptions< @@ -33,7 +33,7 @@ export type CreateBaseQueryOptions< export type CreateBaseQueryResult< TData = unknown, TError = DefaultError, -> = Readable> +> = QueryObserverResult /** Options for createQuery */ export type CreateQueryOptions< @@ -68,13 +68,13 @@ export type CreateInfiniteQueryOptions< export type CreateInfiniteQueryResult< TData = unknown, TError = DefaultError, -> = Readable> +> = InfiniteQueryObserverResult /** Options for createBaseQuery with initialData */ export type DefinedCreateBaseQueryResult< TData = unknown, TError = DefaultError, -> = Readable> +> = DefinedQueryObserverResult /** Options for createQuery with initialData */ export type DefinedCreateQueryResult< @@ -134,9 +134,7 @@ export type CreateMutationResult< TError = DefaultError, TVariables = unknown, TOnMutateResult = unknown, -> = Readable< - CreateBaseMutationResult -> +> = CreateBaseMutationResult /** Options for useMutationState */ export type MutationStateOptions = { @@ -145,3 +143,8 @@ export type MutationStateOptions = { mutation: Mutation, ) => TResult } + +export type QueryClientProviderProps = { + client: QueryClient + children: Snippet +} diff --git a/packages/svelte-query/src/useIsFetching.svelte.ts b/packages/svelte-query/src/useIsFetching.svelte.ts new file mode 100644 index 0000000000..0b8c47e3fd --- /dev/null +++ b/packages/svelte-query/src/useIsFetching.svelte.ts @@ -0,0 +1,16 @@ +import { ReactiveValue } from './containers.svelte.js' +import { useQueryClient } from './useQueryClient.js' +import type { QueryClient, QueryFilters } from '@tanstack/query-core' + +export function useIsFetching( + filters?: QueryFilters, + queryClient?: QueryClient, +): ReactiveValue { + const client = useQueryClient(queryClient) + const queryCache = client.getQueryCache() + + return new ReactiveValue( + () => client.isFetching(filters), + (update) => queryCache.subscribe(update), + ) +} diff --git a/packages/svelte-query/src/useIsFetching.ts b/packages/svelte-query/src/useIsFetching.ts deleted file mode 100644 index e784896192..0000000000 --- a/packages/svelte-query/src/useIsFetching.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { notifyManager } from '@tanstack/query-core' -import { readable } from 'svelte/store' -import { useQueryClient } from './useQueryClient.js' -import type { Readable } from 'svelte/store' -import type { QueryClient, QueryFilters } from '@tanstack/query-core' - -export function useIsFetching( - filters?: QueryFilters, - queryClient?: QueryClient, -): Readable { - const client = useQueryClient(queryClient) - const cache = client.getQueryCache() - // isFetching is the prev value initialized on mount * - let isFetching = client.isFetching(filters) - - const { subscribe } = readable(isFetching, (set) => { - return cache.subscribe( - notifyManager.batchCalls(() => { - const newIsFetching = client.isFetching(filters) - if (isFetching !== newIsFetching) { - // * and update with each change - isFetching = newIsFetching - set(isFetching) - } - }), - ) - }) - - return { subscribe } -} diff --git a/packages/svelte-query/src/useIsMutating.svelte.ts b/packages/svelte-query/src/useIsMutating.svelte.ts new file mode 100644 index 0000000000..21ac56e7a8 --- /dev/null +++ b/packages/svelte-query/src/useIsMutating.svelte.ts @@ -0,0 +1,16 @@ +import { useQueryClient } from './useQueryClient.js' +import { ReactiveValue } from './containers.svelte.js' +import type { MutationFilters, QueryClient } from '@tanstack/query-core' + +export function useIsMutating( + filters?: MutationFilters, + queryClient?: QueryClient, +): ReactiveValue { + const client = useQueryClient(queryClient) + const cache = client.getMutationCache() + + return new ReactiveValue( + () => client.isMutating(filters), + (update) => cache.subscribe(update), + ) +} diff --git a/packages/svelte-query/src/useIsMutating.ts b/packages/svelte-query/src/useIsMutating.ts deleted file mode 100644 index 258e8fe98c..0000000000 --- a/packages/svelte-query/src/useIsMutating.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { notifyManager } from '@tanstack/query-core' -import { readable } from 'svelte/store' -import { useQueryClient } from './useQueryClient.js' -import type { Readable } from 'svelte/store' -import type { MutationFilters, QueryClient } from '@tanstack/query-core' - -export function useIsMutating( - filters?: MutationFilters, - queryClient?: QueryClient, -): Readable { - const client = useQueryClient(queryClient) - const cache = client.getMutationCache() - // isMutating is the prev value initialized on mount * - let isMutating = client.isMutating(filters) - - const { subscribe } = readable(isMutating, (set) => { - return cache.subscribe( - notifyManager.batchCalls(() => { - const newIisMutating = client.isMutating(filters) - if (isMutating !== newIisMutating) { - // * and update with each change - isMutating = newIisMutating - set(isMutating) - } - }), - ) - }) - - return { subscribe } -} diff --git a/packages/svelte-query/src/useIsRestoring.ts b/packages/svelte-query/src/useIsRestoring.ts index c22d8af402..99dd4ddacb 100644 --- a/packages/svelte-query/src/useIsRestoring.ts +++ b/packages/svelte-query/src/useIsRestoring.ts @@ -1,6 +1,6 @@ import { getIsRestoringContext } from './context.js' -import type { Readable } from 'svelte/store' +import type { Box } from './containers.svelte.js' -export function useIsRestoring(): Readable { +export function useIsRestoring(): Box { return getIsRestoringContext() } diff --git a/packages/svelte-query/src/useMutationState.svelte.ts b/packages/svelte-query/src/useMutationState.svelte.ts new file mode 100644 index 0000000000..c517e64b48 --- /dev/null +++ b/packages/svelte-query/src/useMutationState.svelte.ts @@ -0,0 +1,56 @@ +import { replaceEqualDeep } from '@tanstack/query-core' +import { useQueryClient } from './useQueryClient.js' +import type { + MutationCache, + MutationState, + QueryClient, +} from '@tanstack/query-core' +import type { MutationStateOptions } from './types.js' + +function getResult( + mutationCache: MutationCache, + options: MutationStateOptions, +): Array { + return mutationCache + .findAll(options.filters) + .map( + (mutation): TResult => + (options.select ? options.select(mutation) : mutation.state) as TResult, + ) +} + +export function useMutationState( + options: MutationStateOptions = {}, + queryClient?: QueryClient, +): Array { + const mutationCache = useQueryClient(queryClient).getMutationCache() + const result = $state(getResult(mutationCache, options)) + + $effect(() => { + const unsubscribe = mutationCache.subscribe(() => { + const nextResult = replaceEqualDeep( + result, + getResult(mutationCache, options), + ) + if (result !== nextResult) { + Object.assign(result, nextResult) + } + }) + + return unsubscribe + }) + + /* $effect(() => { + mutationCache.subscribe(() => { + const nextResult = replaceEqualDeep( + result.current, + getResult(mutationCache, optionsRef), + ) + if (result.current !== nextResult) { + result = nextResult + //notifyManager.schedule(onStoreChange) + } + }) + }) */ + return result +} diff --git a/packages/svelte-query/src/useMutationState.ts b/packages/svelte-query/src/useMutationState.ts deleted file mode 100644 index 0367eee5db..0000000000 --- a/packages/svelte-query/src/useMutationState.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { readable } from 'svelte/store' -import { notifyManager, replaceEqualDeep } from '@tanstack/query-core' -import { useQueryClient } from './useQueryClient.js' -import type { - MutationCache, - MutationState, - QueryClient, -} from '@tanstack/query-core' -import type { Readable } from 'svelte/store' -import type { MutationStateOptions } from './types.js' - -function getResult( - mutationCache: MutationCache, - options: MutationStateOptions, -): Array { - return mutationCache - .findAll(options.filters) - .map( - (mutation): TResult => - (options.select ? options.select(mutation) : mutation.state) as TResult, - ) -} - -export function useMutationState( - options: MutationStateOptions = {}, - queryClient?: QueryClient, -): Readable> { - const client = useQueryClient(queryClient) - const mutationCache = client.getMutationCache() - - let result = getResult(mutationCache, options) - - const { subscribe } = readable(result, (set) => { - return mutationCache.subscribe( - notifyManager.batchCalls(() => { - const nextResult = replaceEqualDeep( - result, - getResult(mutationCache, options), - ) - if (result !== nextResult) { - result = nextResult - set(result) - } - }), - ) - }) - - return { subscribe } -} diff --git a/packages/svelte-query/src/utils.svelte.ts b/packages/svelte-query/src/utils.svelte.ts new file mode 100644 index 0000000000..9e8073aab7 --- /dev/null +++ b/packages/svelte-query/src/utils.svelte.ts @@ -0,0 +1,44 @@ +import { untrack } from 'svelte' +// modified from the great https://github.com/svecosystem/runed +function runEffect( + flush: 'post' | 'pre', + effect: () => void | VoidFunction, +): void { + switch (flush) { + case 'post': + $effect(effect) + break + case 'pre': + $effect.pre(effect) + break + } +} +type Getter = () => T +export const watchChanges = ( + sources: Getter | Array>, + flush: 'post' | 'pre', + effect: ( + values: T | Array, + previousValues: T | undefined | Array, + ) => void, +) => { + let active = false + let previousValues: T | undefined | Array = Array.isArray( + sources, + ) + ? [] + : undefined + runEffect(flush, () => { + const values = Array.isArray(sources) + ? sources.map((source) => source()) + : sources() + if (!active) { + active = true + previousValues = values + return + } + const cleanup = untrack(() => effect(values, previousValues)) + previousValues = values + return cleanup + }) +} diff --git a/packages/svelte-query/src/utils.ts b/packages/svelte-query/src/utils.ts deleted file mode 100644 index 35d60730aa..0000000000 --- a/packages/svelte-query/src/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Readable } from 'svelte/store' -import type { StoreOrVal } from './types.js' - -export function isSvelteStore( - obj: StoreOrVal, -): obj is Readable { - return 'subscribe' in obj && typeof obj.subscribe === 'function' -} diff --git a/packages/svelte-query/svelte.config.js b/packages/svelte-query/svelte.config.js index 94ca454ac7..076d2dcd50 100644 --- a/packages/svelte-query/svelte.config.js +++ b/packages/svelte-query/svelte.config.js @@ -2,6 +2,9 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' const config = { preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, } export default config diff --git a/packages/svelte-query/tests/ProviderWrapper.svelte b/packages/svelte-query/tests/ProviderWrapper.svelte new file mode 100644 index 0000000000..b61d2d99da --- /dev/null +++ b/packages/svelte-query/tests/ProviderWrapper.svelte @@ -0,0 +1,14 @@ + + + + {@render children()} + diff --git a/packages/svelte-query/tests/QueryClientProvider/ChildComponent.svelte b/packages/svelte-query/tests/QueryClientProvider/ChildComponent.svelte index 7fef03d0d9..f025d2e365 100644 --- a/packages/svelte-query/tests/QueryClientProvider/ChildComponent.svelte +++ b/packages/svelte-query/tests/QueryClientProvider/ChildComponent.svelte @@ -2,10 +2,10 @@ import { createQuery } from '../../src/index.js' import { sleep } from '@tanstack/query-test-utils' - const query = createQuery({ + const query = createQuery(() => ({ queryKey: ['hello'], queryFn: () => sleep(10).then(() => 'test'), - }) + })) -
Data: {$query.data ?? 'undefined'}
+
Data: {query.data ?? 'undefined'}
diff --git a/packages/svelte-query/tests/QueryClientProvider/ParentComponent.svelte b/packages/svelte-query/tests/QueryClientProvider/ParentComponent.svelte index dc2440ef94..c7b6fa0c5b 100644 --- a/packages/svelte-query/tests/QueryClientProvider/ParentComponent.svelte +++ b/packages/svelte-query/tests/QueryClientProvider/ParentComponent.svelte @@ -1,12 +1,9 @@ diff --git a/packages/svelte-query/tests/QueryClientProvider/QueryClientProvider.test.ts b/packages/svelte-query/tests/QueryClientProvider/QueryClientProvider.svelte.test.ts similarity index 78% rename from packages/svelte-query/tests/QueryClientProvider/QueryClientProvider.test.ts rename to packages/svelte-query/tests/QueryClientProvider/QueryClientProvider.svelte.test.ts index 66a40915f5..754e492fcc 100644 --- a/packages/svelte-query/tests/QueryClientProvider/QueryClientProvider.test.ts +++ b/packages/svelte-query/tests/QueryClientProvider/QueryClientProvider.svelte.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { render } from '@testing-library/svelte' -import { QueryCache } from '@tanstack/query-core' +import { QueryClient } from '@tanstack/query-core' import ParentComponent from './ParentComponent.svelte' describe('QueryClientProvider', () => { @@ -13,11 +13,12 @@ describe('QueryClientProvider', () => { }) test('Sets a specific cache for all queries to use', async () => { - const queryCache = new QueryCache() + const queryClient = new QueryClient() + const queryCache = queryClient.getQueryCache() const rendered = render(ParentComponent, { props: { - queryCache: queryCache, + queryClient: queryClient, }, }) diff --git a/packages/svelte-query/tests/containers.svelte.test.ts b/packages/svelte-query/tests/containers.svelte.test.ts new file mode 100644 index 0000000000..3511dbb5b5 --- /dev/null +++ b/packages/svelte-query/tests/containers.svelte.test.ts @@ -0,0 +1,219 @@ +import { flushSync } from 'svelte' +import { describe, expect, it } from 'vitest' +import { createRawRef } from '../src/containers.svelte.js' +import { withEffectRoot } from './utils.svelte.js' + +describe('createRawRef', () => { + it('should create a reactive reference', () => { + const [ref, update] = createRawRef({ a: 1, b: 2 }) + + expect(ref).toEqual({ a: 1, b: 2 }) + + update({ a: 3, b: 4 }) + expect(ref).toEqual({ a: 3, b: 4 }) + + ref.a = 5 + expect(ref).toEqual({ a: 5, b: 4 }) + }) + + it('should handle nested objects', () => { + const [ref, update] = createRawRef<{ a: any }>({ a: { b: { c: 1 } } }) + + expect(ref).toEqual({ a: { b: { c: 1 } } }) + + // update with same structure + update({ a: { b: { c: 2 } } }) + expect(ref).toEqual({ a: { b: { c: 2 } } }) + + ref.a.b.c = 3 + expect(ref).toEqual({ a: { b: { c: 3 } } }) + + // update with different structure should wipe out everything below the first level + update({ a: { b: 3 } }) + expect(ref).toEqual({ a: { b: 3 } }) + }) + + it('should remove properties when a new object is assigned', () => { + const [ref, update] = createRawRef>({ + a: 1, + b: 2, + }) + + expect(ref).toEqual({ a: 1, b: 2 }) + + update({ a: 3 }) + expect(ref).toEqual({ a: 3 }) + }) + + it( + 'should not break reactivity when removing keys', + withEffectRoot(() => { + const [ref, update] = createRawRef>({ a: 1, b: 2 }) + const states: Array = [] + $effect(() => { + states.push(ref.b) + }) + + // these flushSync calls force the effect to run and push the value to the states array + flushSync() + update({ a: 3 }) // should remove b, and should rerun the effect + flushSync() + update({ a: 3, b: 4 }) // should add b back, and should rerun the effect + flushSync() + delete ref.b // should remove b, and should rerun the effect + flushSync() + delete ref.a // should remove a, and should _not_ rerun the effect + expect(states).toEqual([2, undefined, 4, undefined]) + }), + ) + + it( + 'should correctly trap calls to `in`', + withEffectRoot(() => { + const [ref, update] = createRawRef>({ + a: 1, + b: 2, + }) + + expect('b' in ref).toBe(true) + delete ref.b + expect('b' in ref).toBe(false) + update({}) + expect('a' in ref).toBe(false) + update({ a: 1, b: 2 }) + expect('b' in ref).toBe(true) + expect('a' in ref).toBe(true) + }), + ) + + it('should correctly trap calls to `ownKeys`', () => { + const [ref, update] = createRawRef>({ + a: 1, + b: 2, + }) + + expect(Object.keys(ref)).toEqual(['a', 'b']) + + delete ref.b + expect(Reflect.ownKeys(ref)).toEqual(['a']) + + update({}) + expect(Object.keys(ref)).toEqual([]) + + update({ a: 1, b: 2 }) + expect(Object.keys(ref)).toEqual(['a', 'b']) + }) + + it('should correctly trap calls to `getOwnPropertyDescriptor`', () => { + const [ref, update] = createRawRef>({ + a: 1, + b: 2, + }) + + expect(Reflect.getOwnPropertyDescriptor(ref, 'b')).toEqual({ + configurable: true, + enumerable: true, + get: expect.any(Function), + set: expect.any(Function), + }) + + delete ref.b + expect(Reflect.getOwnPropertyDescriptor(ref, 'b')).toEqual(undefined) + + update({}) + expect(Reflect.getOwnPropertyDescriptor(ref, 'a')).toEqual(undefined) + + update({ a: 1, b: 2 }) + expect(Reflect.getOwnPropertyDescriptor(ref, 'a')).toEqual({ + configurable: true, + enumerable: true, + get: expect.any(Function), + set: expect.any(Function), + }) + expect(Reflect.getOwnPropertyDescriptor(ref, 'b')).toEqual({ + configurable: true, + enumerable: true, + get: expect.any(Function), + set: expect.any(Function), + }) + }) + + it('should lazily access values when using `update`', () => { + let aAccessed = false + let bAccessed = false + const [ref, update] = createRawRef({ + get a() { + aAccessed = true + return 1 + }, + get b() { + bAccessed = true + return 2 + }, + }) + + expect(aAccessed).toBe(false) + expect(bAccessed).toBe(false) + + expect(ref.a).toBe(1) + + expect(aAccessed).toBe(true) + expect(bAccessed).toBe(false) + + aAccessed = false + bAccessed = false + + update({ + get a() { + aAccessed = true + return 2 + }, + get b() { + bAccessed = true + return 3 + }, + }) + + expect(aAccessed).toBe(false) + expect(bAccessed).toBe(false) + + expect(ref.a).toBe(2) + + expect(aAccessed).toBe(true) + expect(bAccessed).toBe(false) + }) + + it('should handle arrays', () => { + const [ref, update] = createRawRef([1, 2, 3]) + + expect(ref).toEqual([1, 2, 3]) + + ref[0] = 4 + expect(ref).toEqual([4, 2, 3]) + + update([5, 6]) + expect(ref).toEqual([5, 6]) + + update([7, 8, 9]) + expect(ref).toEqual([7, 8, 9]) + }) + + it('should behave like a regular object when not using `update`', () => { + const [ref] = createRawRef>({ a: 1, b: 2 }) + + expect(ref).toEqual({ a: 1, b: 2 }) + + ref.a = 3 + expect(ref).toEqual({ a: 3, b: 2 }) + + ref.b = 4 + expect(ref).toEqual({ a: 3, b: 4 }) + + ref.c = 5 + expect(ref).toEqual({ a: 3, b: 4, c: 5 }) + + ref.fn = () => 6 + expect(ref).toEqual({ a: 3, b: 4, c: 5, fn: expect.any(Function) }) + expect((ref.fn as () => number)()).toBe(6) + }) +}) diff --git a/packages/svelte-query/tests/context/context.test.ts b/packages/svelte-query/tests/context/context.svelte.test.ts similarity index 100% rename from packages/svelte-query/tests/context/context.test.ts rename to packages/svelte-query/tests/context/context.svelte.test.ts diff --git a/packages/svelte-query/tests/createInfiniteQuery/BaseExample.svelte b/packages/svelte-query/tests/createInfiniteQuery/BaseExample.svelte index 3ad7a0d51c..a16cdc9214 100644 --- a/packages/svelte-query/tests/createInfiniteQuery/BaseExample.svelte +++ b/packages/svelte-query/tests/createInfiniteQuery/BaseExample.svelte @@ -1,25 +1,29 @@ -
Status: {$query.status}
+
Status: {query.status}
diff --git a/packages/svelte-query/tests/createInfiniteQuery/ChangeClient.svelte b/packages/svelte-query/tests/createInfiniteQuery/ChangeClient.svelte index a5d547c35c..1993dca149 100644 --- a/packages/svelte-query/tests/createInfiniteQuery/ChangeClient.svelte +++ b/packages/svelte-query/tests/createInfiniteQuery/ChangeClient.svelte @@ -3,25 +3,25 @@ import { createInfiniteQuery } from '../../src/index.js' import { sleep } from '@tanstack/query-test-utils' - export let queryClient: QueryClient + let { queryClient }: { queryClient: QueryClient } = $props() const queryKey = ['test'] - let firstPage = 0 + let firstPage = $state(0) const query = createInfiniteQuery( - { + () => ({ queryKey: queryKey, queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), getNextPageParam: (lastPage) => lastPage + 1, initialPageParam: firstPage, - }, - queryClient, + }), + () => queryClient, ) -
Data: {JSON.stringify($query.data)}
+
Data: {JSON.stringify(query.data)}
diff --git a/packages/svelte-query/tests/createInfiniteQuery/SelectExample.svelte b/packages/svelte-query/tests/createInfiniteQuery/SelectExample.svelte index d860bfbd7b..84f7247136 100644 --- a/packages/svelte-query/tests/createInfiniteQuery/SelectExample.svelte +++ b/packages/svelte-query/tests/createInfiniteQuery/SelectExample.svelte @@ -1,16 +1,16 @@ -
{$query.data?.pages.join(',')}
+
{query.data?.pages.join(',')}
diff --git a/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.test.ts b/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.svelte.test.ts similarity index 86% rename from packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.test.ts rename to packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.svelte.test.ts index 14c263e2a2..344cd902b8 100644 --- a/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.test.ts +++ b/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.svelte.test.ts @@ -1,11 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { QueryClient } from '@tanstack/query-core' import { fireEvent, render } from '@testing-library/svelte' -import { get, writable } from 'svelte/store' +import { QueryClient } from '@tanstack/query-core' +import { ref } from '../utils.svelte.js' import BaseExample from './BaseExample.svelte' import SelectExample from './SelectExample.svelte' import ChangeClient from './ChangeClient.svelte' -import type { Writable } from 'svelte/store' import type { QueryObserverResult } from '@tanstack/query-core' describe('createInfiniteQuery', () => { @@ -18,21 +17,20 @@ describe('createInfiniteQuery', () => { }) it('should return the correct states for a successful query', async () => { - const statesStore: Writable> = writable([]) + let states = ref>([]) const rendered = render(BaseExample, { props: { - states: statesStore, + states, }, }) await vi.advanceTimersByTimeAsync(11) expect(rendered.getByText('Status: success')).toBeInTheDocument() - const states = get(statesStore) + expect(states.value).toHaveLength(2) - expect(states).toHaveLength(2) - expect(states[0]).toEqual({ + expect(states.value[0]).toEqual({ data: undefined, dataUpdatedAt: 0, error: null, @@ -68,7 +66,8 @@ describe('createInfiniteQuery', () => { fetchStatus: 'fetching', promise: expect.any(Promise), }) - expect(states[1]).toEqual({ + + expect(states.value[1]).toEqual({ data: { pages: [0], pageParams: [0] }, dataUpdatedAt: expect.any(Number), error: null, @@ -107,25 +106,25 @@ describe('createInfiniteQuery', () => { }) it('should be able to select a part of the data', async () => { - const statesStore: Writable> = writable([]) + let states = ref>([]) const rendered = render(SelectExample, { props: { - states: statesStore, + states, }, }) await vi.advanceTimersByTimeAsync(11) expect(rendered.getByText('count: 1')).toBeInTheDocument() - const states = get(statesStore) + expect(states.value).toHaveLength(2) - expect(states).toHaveLength(2) - expect(states[0]).toMatchObject({ + expect(states.value[0]).toMatchObject({ data: undefined, isSuccess: false, }) - expect(states[1]).toMatchObject({ + + expect(states.value[1]).toMatchObject({ data: { pages: ['count: 1'] }, isSuccess: true, }) diff --git a/packages/svelte-query/tests/createMutation/FailureExample.svelte b/packages/svelte-query/tests/createMutation/FailureExample.svelte index 1ed739a622..ac3cd58633 100644 --- a/packages/svelte-query/tests/createMutation/FailureExample.svelte +++ b/packages/svelte-query/tests/createMutation/FailureExample.svelte @@ -1,23 +1,23 @@ - + -
Data: {$mutation.data?.count ?? 'undefined'}
-
Status: {$mutation.status}
-
Failure Count: {$mutation.failureCount}
-
Failure Reason: {$mutation.failureReason ?? 'undefined'}
+
Data: {mutation.data?.count ?? 'undefined'}
+
Status: {mutation.status}
+
Failure Count: {mutation.failureCount}
+
Failure Reason: {mutation.failureReason ?? 'undefined'}
diff --git a/packages/svelte-query/tests/createMutation/OnSuccessExample.svelte b/packages/svelte-query/tests/createMutation/OnSuccessExample.svelte index ae9ba3da02..92cfc4a752 100644 --- a/packages/svelte-query/tests/createMutation/OnSuccessExample.svelte +++ b/packages/svelte-query/tests/createMutation/OnSuccessExample.svelte @@ -1,18 +1,21 @@ - + -
Count: {$count}
+
Count: {count}
diff --git a/packages/svelte-query/tests/createMutation/ResetExample.svelte b/packages/svelte-query/tests/createMutation/ResetExample.svelte index 04e0987362..060e936a35 100644 --- a/packages/svelte-query/tests/createMutation/ResetExample.svelte +++ b/packages/svelte-query/tests/createMutation/ResetExample.svelte @@ -6,13 +6,13 @@ const queryClient = new QueryClient() setQueryClientContext(queryClient) - const mutation = createMutation({ + const mutation = createMutation(() => ({ mutationFn: () => sleep(10).then(() => Promise.reject(new Error('Expected mock error'))), - }) + })) - - + + -
Error: {$mutation.error?.message ?? 'undefined'}
+
Error: {mutation.error?.message ?? 'undefined'}
diff --git a/packages/svelte-query/tests/createMutation/createMutation.test.ts b/packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts similarity index 100% rename from packages/svelte-query/tests/createMutation/createMutation.test.ts rename to packages/svelte-query/tests/createMutation/createMutation.svelte.test.ts diff --git a/packages/svelte-query/tests/createQueries.svelte.test.ts b/packages/svelte-query/tests/createQueries.svelte.test.ts new file mode 100644 index 0000000000..c648942483 --- /dev/null +++ b/packages/svelte-query/tests/createQueries.svelte.test.ts @@ -0,0 +1,934 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { QueryClient, createQueries } from '../src/index.js' +import { promiseWithResolvers, withEffectRoot } from './utils.svelte.js' +import type { + CreateQueryOptions, + CreateQueryResult, + QueryFunction, + QueryFunctionContext, + QueryKey, + skipToken, +} from '../src/index.js' + +describe('createQueries', () => { + const queryClient = new QueryClient() + + afterEach(() => { + queryClient.clear() + }) + + it( + 'should return the correct states', + withEffectRoot(async () => { + const key1 = ['test-1'] + const key2 = ['test-2'] + const results: Array> = [] + const { promise: promise1, resolve: resolve1 } = promiseWithResolvers() + const { promise: promise2, resolve: resolve2 } = promiseWithResolvers() + + const result = createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => promise1, + }, + { + queryKey: key2, + queryFn: () => promise2, + }, + ], + }), + () => queryClient, + ) + + $effect(() => { + results.push([{ ...result[0] }, { ...result[1] }]) + }) + + resolve1(1) + + await vi.waitFor(() => expect(result[0].data).toBe(1)) + + resolve2(2) + await vi.waitFor(() => expect(result[1].data).toBe(2)) + + expect(results.length).toBe(3) + expect(results[0]).toMatchObject([ + { data: undefined }, + { data: undefined }, + ]) + expect(results[1]).toMatchObject([{ data: 1 }, { data: undefined }]) + expect(results[2]).toMatchObject([{ data: 1 }, { data: 2 }]) + }), + ) + + it( + 'handles type parameter - tuple of tuples', + withEffectRoot(() => { + const key1 = ['test-key-1'] + const key2 = ['test-key-2'] + const key3 = ['test-key-3'] + + const result1 = createQueries< + [[number], [string], [Array, boolean]] + >( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 1, + }, + { + queryKey: key2, + queryFn: () => 'string', + }, + { + queryKey: key3, + queryFn: () => ['string[]'], + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(result1[0]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result1[1]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result1[2]).toEqualTypeOf< + CreateQueryResult, boolean> + >() + expectTypeOf(result1[0].data).toEqualTypeOf() + expectTypeOf(result1[1].data).toEqualTypeOf() + expectTypeOf(result1[2].data).toEqualTypeOf | undefined>() + expectTypeOf(result1[2].error).toEqualTypeOf() + + // TData (3rd element) takes precedence over TQueryFnData (1st element) + const result2 = createQueries< + [[string, unknown, string], [string, unknown, number]] + >( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a.toLowerCase() + }, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return parseInt(a) + }, + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(result2[0]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result2[1]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result2[0].data).toEqualTypeOf() + expectTypeOf(result2[1].data).toEqualTypeOf() + + // types should be enforced + createQueries<[[string, unknown, string], [string, boolean, number]]>( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a.toLowerCase() + }, + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return parseInt(a) + }, + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + ], + }), + () => queryClient, + ) + + // field names should be enforced + createQueries<[[string]]>( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + }, + ], + }), + () => queryClient, + ) + }), + ) + + it( + 'handles type parameter - tuple of objects', + withEffectRoot(() => { + const key1 = ['test-key-1'] + const key2 = ['test-key-2'] + const key3 = ['test-key-3'] + + const result1 = createQueries< + [ + { queryFnData: number }, + { queryFnData: string }, + { queryFnData: Array; error: boolean }, + ] + >( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 1, + }, + { + queryKey: key2, + queryFn: () => 'string', + }, + { + queryKey: key3, + queryFn: () => ['string[]'], + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(result1[0]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result1[1]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result1[2]).toEqualTypeOf< + CreateQueryResult, boolean> + >() + expectTypeOf(result1[0].data).toEqualTypeOf() + expectTypeOf(result1[1].data).toEqualTypeOf() + expectTypeOf(result1[2].data).toEqualTypeOf | undefined>() + expectTypeOf(result1[2].error).toEqualTypeOf() + + // TData (data prop) takes precedence over TQueryFnData (queryFnData prop) + const result2 = createQueries< + [ + { queryFnData: string; data: string }, + { queryFnData: string; data: number }, + ] + >( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a.toLowerCase() + }, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return parseInt(a) + }, + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(result2[0]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result2[1]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result2[0].data).toEqualTypeOf() + expectTypeOf(result2[1].data).toEqualTypeOf() + + // can pass only TData (data prop) although TQueryFnData will be left unknown + const result3 = createQueries<[{ data: string }, { data: number }]>( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a as string + }, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a as number + }, + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(result3[0]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result3[1]).toEqualTypeOf< + CreateQueryResult + >() + expectTypeOf(result3[0].data).toEqualTypeOf() + expectTypeOf(result3[1].data).toEqualTypeOf() + + // types should be enforced + createQueries< + [ + { queryFnData: string; data: string }, + { queryFnData: string; data: number; error: boolean }, + ] + >( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return a.toLowerCase() + }, + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + { + queryKey: key2, + queryFn: () => 'string', + select: (a) => { + expectTypeOf(a).toEqualTypeOf() + return parseInt(a) + }, + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + ], + }), + () => queryClient, + ) + + // field names should be enforced + createQueries<[{ queryFnData: string }]>( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + }, + ], + }), + () => queryClient, + ) + }), + ) + + it( + 'handles array literal without type parameter to infer result type', + withEffectRoot(() => { + const key1 = ['test-key-1'] + const key2 = ['test-key-2'] + const key3 = ['test-key-3'] + const key4 = ['test-key-4'] + + // Array.map preserves TQueryFnData + const result1 = createQueries( + () => ({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => i + 10, + })), + }), + () => queryClient, + ) + + expectTypeOf(result1).toEqualTypeOf< + Array> + >() + if (result1[0]) { + expectTypeOf(result1[0].data).toEqualTypeOf() + } + + // Array.map preserves TData + const result2 = createQueries( + () => ({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => i + 10, + select: (data: number) => data.toString(), + })), + }), + () => queryClient, + ) + + expectTypeOf(result2).toEqualTypeOf< + Array> + >() + + const result3 = createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 1, + }, + { + queryKey: key2, + queryFn: () => 'string', + }, + { + queryKey: key3, + queryFn: () => ['string[]'], + select: () => 123, + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(result3[0]).toEqualTypeOf>() + expectTypeOf(result3[1]).toEqualTypeOf>() + expectTypeOf(result3[2]).toEqualTypeOf>() + expectTypeOf(result3[0].data).toEqualTypeOf() + expectTypeOf(result3[1].data).toEqualTypeOf() + // select takes precedence over queryFn + expectTypeOf(result3[2].data).toEqualTypeOf() + + // initialData/placeholderData are enforced + createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + placeholderData: 'string', + // @ts-expect-error (initialData: string) + initialData: 123, + }, + { + queryKey: key2, + queryFn: () => 123, + // @ts-expect-error (placeholderData: number) + placeholderData: 'string', + initialData: 123, + }, + ], + }), + () => queryClient, + ) + + // select params are "indirectly" enforced + createQueries( + () => ({ + queries: [ + // unfortunately TS will not suggest the type for you + { + queryKey: key1, + queryFn: () => 'string', + }, + // however you can add a type to the callback + { + queryKey: key2, + queryFn: () => 'string', + }, + // the type you do pass is enforced + { + queryKey: key3, + queryFn: () => 'string', + }, + { + queryKey: key4, + queryFn: () => 'string', + select: (a: string) => parseInt(a), + }, + ], + }), + () => queryClient, + ) + + // callbacks are also indirectly enforced with Array.map + createQueries( + () => ({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => i + 10, + select: (data: number) => data.toString(), + })), + }), + () => queryClient, + ) + + // results inference works when all the handlers are defined + const result4 = createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + }, + { + queryKey: key2, + queryFn: () => 'string', + }, + { + queryKey: key4, + queryFn: () => 'string', + select: (a: string) => parseInt(a), + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(result4[0]).toEqualTypeOf>() + expectTypeOf(result4[1]).toEqualTypeOf>() + expectTypeOf(result4[2]).toEqualTypeOf>() + + // handles when queryFn returns a Promise + const result5 = createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => Promise.resolve('string'), + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(result5[0]).toEqualTypeOf>() + + // Array as const does not throw error + const result6 = createQueries( + () => + ({ + queries: [ + { + queryKey: ['key1'], + queryFn: () => 'string', + }, + { + queryKey: ['key1'], + queryFn: () => 123, + }, + ], + }) as const, + () => queryClient, + ) + + expectTypeOf(result6[0]).toEqualTypeOf>() + expectTypeOf(result6[1]).toEqualTypeOf>() + + // field names should be enforced - array literal + createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => 'string', + }, + ], + }), + () => queryClient, + ) + + // field names should be enforced - Array.map() result + createQueries( + () => ({ + // @ts-expect-error (invalidField) + queries: Array(10).map(() => ({ + someInvalidField: '', + })), + }), + () => queryClient, + ) + + // supports queryFn using fetch() to return Promise - Array.map() result + createQueries( + () => ({ + queries: Array(50).map((_, i) => ({ + queryKey: ['key', i] as const, + queryFn: () => + fetch('return Promise').then((resp) => resp.json()), + })), + }), + () => queryClient, + ) + + // supports queryFn using fetch() to return Promise - array literal + createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => + fetch('return Promise').then((resp) => resp.json()), + }, + ], + }), + () => queryClient, + ) + }), + ) + + it( + 'handles strongly typed queryFn factories and createQueries wrappers', + withEffectRoot(() => { + // QueryKey + queryFn factory + type QueryKeyA = ['queryA'] + const getQueryKeyA = (): QueryKeyA => ['queryA'] + type GetQueryFunctionA = () => QueryFunction + const getQueryFunctionA: GetQueryFunctionA = () => () => { + return 1 + } + type SelectorA = (data: number) => [number, string] + const getSelectorA = (): SelectorA => (data) => [data, data.toString()] + + type QueryKeyB = ['queryB', string] + const getQueryKeyB = (id: string): QueryKeyB => ['queryB', id] + type GetQueryFunctionB = () => QueryFunction + const getQueryFunctionB: GetQueryFunctionB = () => () => { + return '1' + } + type SelectorB = (data: string) => [string, number] + const getSelectorB = (): SelectorB => (data) => [data, +data] + + // Wrapper with strongly typed array-parameter + function useWrappedQueries< + TQueryFnData, + TError, + TData, + TQueryKey extends QueryKey, + >( + queries: Array< + CreateQueryOptions + >, + ) { + return createQueries( + () => ({ + queries: queries.map( + // no need to type the mapped query + (query) => { + const { queryFn: fn, queryKey: key } = query + expectTypeOf(fn).toEqualTypeOf< + | typeof skipToken + | QueryFunction + | undefined + >() + return { + queryKey: key, + queryFn: fn + ? (ctx: QueryFunctionContext) => { + // eslint-disable-next-line vitest/valid-expect + expectTypeOf(ctx.queryKey) + return ( + fn as QueryFunction + ).call({}, ctx) + } + : undefined, + } + }, + ), + }), + () => queryClient, + ) + } + + const result = createQueries( + () => ({ + queries: [ + { + queryKey: getQueryKeyA(), + queryFn: getQueryFunctionA(), + }, + { + queryKey: getQueryKeyB('id'), + queryFn: getQueryFunctionB(), + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(result[0]).toEqualTypeOf>() + expectTypeOf(result[1]).toEqualTypeOf>() + + const withSelector = createQueries( + () => ({ + queries: [ + { + queryKey: getQueryKeyA(), + queryFn: getQueryFunctionA(), + select: getSelectorA(), + }, + { + queryKey: getQueryKeyB('id'), + queryFn: getQueryFunctionB(), + select: getSelectorB(), + }, + ], + }), + () => queryClient, + ) + + expectTypeOf(withSelector[0]).toEqualTypeOf< + CreateQueryResult<[number, string], Error> + >() + expectTypeOf(withSelector[1]).toEqualTypeOf< + CreateQueryResult<[string, number], Error> + >() + + const withWrappedQueries = useWrappedQueries( + Array(10).map(() => ({ + queryKey: getQueryKeyA(), + queryFn: getQueryFunctionA(), + select: getSelectorA(), + })), + ) + + expectTypeOf(withWrappedQueries).toEqualTypeOf< + Array> + >() + }), + ) + + it( + 'should track results', + withEffectRoot(async () => { + const key1 = ['test-track-results'] + const results: Array> = [] + let count = 0 + + const result = createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => Promise.resolve(++count), + }, + ], + }), + () => queryClient, + ) + + $effect(() => { + results.push([result[0]]) + }) + + await vi.waitFor(() => expect(result[0].data).toBe(1)) + + expect(results.length).toBe(2) + expect(results[0]).toMatchObject([{ data: undefined }]) + expect(results[1]).toMatchObject([{ data: 1 }]) + + // Trigger refetch + result[0].refetch() + + await vi.waitFor(() => expect(result[0].data).toBe(2)) + + // Only one render for data update, no render for isFetching transition + expect(results.length).toBe(3) + expect(results[2]).toMatchObject([{ data: 2 }]) + }), + ) + + it( + 'should combine queries', + withEffectRoot(async () => { + const key1 = ['test-combine-1'] + const key2 = ['test-combine-2'] + + const { promise: promise1, resolve: resolve1 } = + promiseWithResolvers() + const { promise: promise2, resolve: resolve2 } = + promiseWithResolvers() + + const queries = createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => promise1, + }, + { + queryKey: key2, + queryFn: () => promise2, + }, + ], + combine: (results) => { + return { + combined: true, + res: results + .flatMap((res) => (res.data ? [res.data] : [])) + .join(','), + } + }, + }), + () => queryClient, + ) + + // Initially both queries are loading + expect(queries).toEqual({ + combined: true, + res: '', + }) + + // Resolve the first query + resolve1('first result') + await vi.waitFor(() => expect(queries.res).toBe('first result')) + + // Resolve the second query + resolve2('second result') + await vi.waitFor(() => + expect(queries.res).toBe('first result,second result'), + ) + + expect(queries).toEqual({ + combined: true, + res: 'first result,second result', + }) + }), + ) + + it( + 'should track property access through combine function', + withEffectRoot(async () => { + const key1 = ['test-track-combine-1'] + const key2 = ['test-track-combine-2'] + let count = 0 + const results: Array = [] + + const { promise: promise1, resolve: resolve1 } = + promiseWithResolvers() + const { promise: promise2, resolve: resolve2 } = + promiseWithResolvers() + const { promise: promise3, resolve: resolve3 } = + promiseWithResolvers() + const { promise: promise4, resolve: resolve4 } = + promiseWithResolvers() + + const queries = createQueries( + () => ({ + queries: [ + { + queryKey: key1, + queryFn: () => (count === 0 ? promise1 : promise3), + }, + { + queryKey: key2, + queryFn: () => (count === 0 ? promise2 : promise4), + }, + ], + combine: (queryResults) => { + return { + combined: true, + refetch: () => + Promise.all(queryResults.map((res) => res.refetch())), + res: queryResults + .flatMap((res) => (res.data ? [res.data] : [])) + .join(','), + } + }, + }), + () => queryClient, + ) + + $effect(() => { + results.push({ ...queries }) + }) + + // Initially both queries are loading + await vi.waitFor(() => + expect(results[0]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: '', + }), + ) + + // Resolve the first query + resolve1('first result ' + count) + await vi.waitFor(() => expect(queries.res).toBe('first result 0')) + + expect(results[1]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 0', + }) + + // Resolve the second query + resolve2('second result ' + count) + await vi.waitFor(() => + expect(queries.res).toBe('first result 0,second result 0'), + ) + + expect(results[2]).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 0,second result 0', + }) + + // Increment count and refetch + count++ + queries.refetch() + + // Resolve the refetched queries + resolve3('first result ' + count) + resolve4('second result ' + count) + + await vi.waitFor(() => + expect(queries.res).toBe('first result 1,second result 1'), + ) + + const length = results.length + expect(results.at(-1)).toStrictEqual({ + combined: true, + refetch: expect.any(Function), + res: 'first result 1,second result 1', + }) + + // Refetch again but with the same data + await queries.refetch() + + // No further re-render because data didn't change + expect(results.length).toBe(length) + }), + ) +}) diff --git a/packages/svelte-query/tests/createQueries.test-d.ts b/packages/svelte-query/tests/createQueries.test-d.ts new file mode 100644 index 0000000000..016f5a53a5 --- /dev/null +++ b/packages/svelte-query/tests/createQueries.test-d.ts @@ -0,0 +1,34 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createQueries, queryOptions } from '../src/index.js' +import type { CreateQueryResult } from '../src/index.js' + +describe('createQueries', () => { + it('should return correct data for dynamic queries with mixed result types', () => { + const Queries1 = { + get: () => + queryOptions({ + queryKey: ['key1'], + queryFn: () => Promise.resolve(1), + }), + } + const Queries2 = { + get: () => + queryOptions({ + queryKey: ['key2'], + queryFn: () => Promise.resolve(true), + }), + } + + const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() })) + const result = createQueries(() => ({ + queries: [...queries1List, { ...Queries2.get() }], + })) + + expectTypeOf(result).toEqualTypeOf< + [ + ...Array>, + CreateQueryResult, + ] + >() + }) +}) diff --git a/packages/svelte-query/tests/createQueries/BaseExample.svelte b/packages/svelte-query/tests/createQueries/BaseExample.svelte deleted file mode 100644 index b9b5ae7c47..0000000000 --- a/packages/svelte-query/tests/createQueries/BaseExample.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - -{#each $queries as query, index} -
Status {index + 1}: {query.status}
-
Data {index + 1}: {query.data}
-{/each} diff --git a/packages/svelte-query/tests/createQueries/CombineExample.svelte b/packages/svelte-query/tests/createQueries/CombineExample.svelte deleted file mode 100644 index a52cc70c42..0000000000 --- a/packages/svelte-query/tests/createQueries/CombineExample.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - -
isPending: {$queries.isPending}
-
Data: {$queries.data ?? 'undefined'}
diff --git a/packages/svelte-query/tests/createQueries/createQueries.test-d.ts b/packages/svelte-query/tests/createQueries/createQueries.test-d.ts deleted file mode 100644 index 0c860ab134..0000000000 --- a/packages/svelte-query/tests/createQueries/createQueries.test-d.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expectTypeOf, test } from 'vitest' -import { get } from 'svelte/store' -import { skipToken } from '@tanstack/query-core' -import { createQueries, queryOptions } from '../../src/index.js' -import type { Readable } from 'svelte/store' -import type { OmitKeyof, QueryObserverResult } from '@tanstack/query-core' -import type { CreateQueryOptions } from '../../src/index.js' - -describe('createQueries', () => { - test('TData should be defined when passed through queryOptions', () => { - const options = queryOptions({ - queryKey: ['key'], - queryFn: () => { - return { - wow: true, - } - }, - initialData: { - wow: true, - }, - }) - const queryResults = createQueries({ queries: [options] }) - - const data = get(queryResults)[0].data - - expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() - }) - - test('Allow custom hooks using UseQueryOptions', () => { - type Data = string - - const useCustomQueries = ( - options?: OmitKeyof, 'queryKey' | 'queryFn'>, - ) => { - return createQueries({ - queries: [ - { - ...options, - queryKey: ['todos-key'], - queryFn: () => Promise.resolve('data'), - }, - ], - }) - } - - const query = useCustomQueries() - const data = get(query)[0].data - - expectTypeOf(data).toEqualTypeOf() - }) - - test('TData should have correct type when conditional skipToken is passed', () => { - const queryResults = createQueries({ - queries: [ - { - queryKey: ['withSkipToken'], - queryFn: Math.random() > 0.5 ? skipToken : () => Promise.resolve(5), - }, - ], - }) - - const firstResult = get(queryResults)[0] - - expectTypeOf(firstResult).toEqualTypeOf< - QueryObserverResult - >() - expectTypeOf(firstResult.data).toEqualTypeOf() - }) - - test('should return correct data for dynamic queries with mixed result types', () => { - const Queries1 = { - get: () => - queryOptions({ - queryKey: ['key1'], - queryFn: () => Promise.resolve(1), - }), - } - const Queries2 = { - get: () => - queryOptions({ - queryKey: ['key2'], - queryFn: () => Promise.resolve(true), - }), - } - - const queries1List = [1, 2, 3].map(() => ({ ...Queries1.get() })) - const result = createQueries({ - queries: [...queries1List, { ...Queries2.get() }], - }) - - expectTypeOf(result).toEqualTypeOf< - Readable< - [ - ...Array>, - QueryObserverResult, - ] - > - >() - - expectTypeOf(get(result)[0].data).toEqualTypeOf< - number | boolean | undefined - >() - }) -}) diff --git a/packages/svelte-query/tests/createQueries/createQueries.test.ts b/packages/svelte-query/tests/createQueries/createQueries.test.ts deleted file mode 100644 index 5f1219aadd..0000000000 --- a/packages/svelte-query/tests/createQueries/createQueries.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { render } from '@testing-library/svelte' -import { QueryClient } from '@tanstack/query-core' -import { sleep } from '@tanstack/query-test-utils' -import BaseExample from './BaseExample.svelte' -import CombineExample from './CombineExample.svelte' - -describe('createQueries', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - test('Render and wait for success', async () => { - const rendered = render(BaseExample, { - props: { - options: { - queries: [ - { - queryKey: ['key-1'], - queryFn: () => sleep(10).then(() => 'Success 1'), - }, - { - queryKey: ['key-2'], - queryFn: () => sleep(10).then(() => 'Success 2'), - }, - ], - }, - queryClient: new QueryClient(), - }, - }) - - expect(rendered.getByText('Status 1: pending')).toBeInTheDocument() - expect(rendered.getByText('Status 2: pending')).toBeInTheDocument() - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Status 1: success')).toBeInTheDocument() - expect(rendered.getByText('Status 2: success')).toBeInTheDocument() - }) - - test('Render and wait for success when queries resolve at different times', async () => { - const rendered = render(BaseExample, { - props: { - options: { - queries: [ - { - queryKey: ['key-1'], - queryFn: () => sleep(10).then(() => 'Success 1'), - }, - { - queryKey: ['key-2'], - queryFn: () => sleep(20).then(() => 'Success 2'), - }, - ], - }, - queryClient: new QueryClient(), - }, - }) - - expect(rendered.getByText('Status 1: pending')).toBeInTheDocument() - expect(rendered.getByText('Status 2: pending')).toBeInTheDocument() - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Status 1: success')).toBeInTheDocument() - await vi.advanceTimersByTimeAsync(10) - expect(rendered.getByText('Status 2: success')).toBeInTheDocument() - }) - - test('Combine queries', async () => { - const rendered = render(CombineExample, { - props: { - queryClient: new QueryClient(), - }, - }) - - expect(rendered.getByText('isPending: true')).toBeInTheDocument() - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Data: 1,2,3')).toBeInTheDocument() - }) -}) diff --git a/packages/svelte-query/tests/createQuery.svelte.test.ts b/packages/svelte-query/tests/createQuery.svelte.test.ts new file mode 100644 index 0000000000..9cec8a17b5 --- /dev/null +++ b/packages/svelte-query/tests/createQuery.svelte.test.ts @@ -0,0 +1,1920 @@ +import { flushSync } from 'svelte' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { sleep } from '@tanstack/query-test-utils' +import { QueryClient, createQuery, keepPreviousData } from '../src/index.js' +import { promiseWithResolvers, withEffectRoot } from './utils.svelte.js' +import type { CreateQueryResult } from '../src/index.js' + +describe('createQuery', () => { + const queryClient = new QueryClient() + const queryCache = queryClient.getQueryCache() + + afterEach(() => { + queryClient.clear() + }) + + it( + 'should return the correct states for a successful query', + withEffectRoot(async () => { + const { promise, resolve } = promiseWithResolvers() + + const query = createQuery( + () => ({ + queryKey: ['test'], + queryFn: () => promise, + }), + () => queryClient, + ) + + if (query.isPending) { + expectTypeOf(query.data).toEqualTypeOf() + expectTypeOf(query.error).toEqualTypeOf() + } else if (query.isLoadingError) { + expectTypeOf(query.data).toEqualTypeOf() + expectTypeOf(query.error).toEqualTypeOf() + } else { + expectTypeOf(query.data).toEqualTypeOf() + expectTypeOf(query.error).toEqualTypeOf() + } + + const promise1 = query.promise + + expect(query).toEqual({ + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isEnabled: true, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isPaused: false, + isPending: true, + isInitialLoading: true, + isLoading: true, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + refetch: expect.any(Function), + status: 'pending', + fetchStatus: 'fetching', + promise: expect.any(Promise), + }) + resolve('resolved') + await vi.waitFor(() => + expect(query).toEqual({ + data: 'resolved', + dataUpdatedAt: expect.any(Number), + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isEnabled: true, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isPaused: false, + isPending: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: true, + refetch: expect.any(Function), + status: 'success', + fetchStatus: 'idle', + promise: expect.any(Promise), + }), + ) + + expect(promise1).toBe(query.promise) + }), + ) + + it( + 'should return the correct states for an unsuccessful query', + withEffectRoot(async () => { + let count = 0 + const states: Array = [] + const query = createQuery( + () => ({ + queryKey: ['test'], + queryFn: () => { + return Promise.reject(new Error('rejected #' + ++count)) + }, + retry: 1, + retryDelay: 1, + }), + () => queryClient, + ) + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => expect(query.isError).toBe(true)) + + expect(states[0]).toEqual({ + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isEnabled: true, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isPaused: false, + isPending: true, + isInitialLoading: true, + isLoading: true, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + refetch: expect.any(Function), + status: 'pending', + fetchStatus: 'fetching', + promise: expect.any(Promise), + }) + + expect(states[1]).toEqual({ + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 1, + failureReason: new Error('rejected #1'), + errorUpdateCount: 0, + isEnabled: true, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isPaused: false, + isPending: true, + isInitialLoading: true, + isLoading: true, + isLoadingError: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + refetch: expect.any(Function), + status: 'pending', + fetchStatus: 'fetching', + promise: expect.any(Promise), + }) + + expect(states[2]).toEqual({ + data: undefined, + dataUpdatedAt: 0, + error: new Error('rejected #2'), + errorUpdatedAt: expect.any(Number), + failureCount: 2, + failureReason: new Error('rejected #2'), + errorUpdateCount: 1, + isEnabled: true, + isError: true, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isPaused: false, + isPending: false, + isInitialLoading: false, + isLoading: false, + isLoadingError: true, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: true, + isSuccess: false, + refetch: expect.any(Function), + status: 'error', + fetchStatus: 'idle', + promise: expect.any(Promise), + }) + }), + ) + + it('should set isFetchedAfterMount to true after a query has been fetched', async () => { + const key = ['test'] + + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => Promise.resolve('prefetched'), + }) + + await withEffectRoot(async () => { + const { promise, resolve } = promiseWithResolvers() + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => promise, + }), + () => queryClient, + ) + + expect(query).toEqual( + expect.objectContaining({ + data: 'prefetched', + isFetched: true, + isFetchedAfterMount: false, + }), + ) + resolve('resolved') + await vi.waitFor(() => + expect(query).toEqual( + expect.objectContaining({ + data: 'resolved', + isFetched: true, + isFetchedAfterMount: true, + }), + ), + ) + })() + }) + + it( + 'should not cancel an ongoing fetch when refetch is called with cancelRefetch=false if we have data already', + withEffectRoot(async () => { + const key = ['test'] + let fetchCount = 0 + + const { promise, resolve } = promiseWithResolvers() + + const { refetch } = createQuery( + () => ({ + queryKey: key, + queryFn: () => { + fetchCount++ + return promise + }, + enabled: false, + initialData: 'initial', + }), + () => queryClient, + ) + + refetch() + refetch({ cancelRefetch: false }) + + resolve('resolved') + await promise + + expect(fetchCount).toBe(1) + }), + ) + + it( + 'should cancel an ongoing fetch when refetch is called (cancelRefetch=true) if we have data already', + withEffectRoot(async () => { + const key = ['test'] + let fetchCount = 0 + + const { promise, resolve } = promiseWithResolvers() + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: async () => { + fetchCount++ + return promise + }, + enabled: false, + initialData: 'initialData', + }), + () => queryClient, + ) + + // Trigger two refetch close together + query.refetch() + query.refetch() + + resolve('resolved') + await promise + + expect(fetchCount).toBe(2) + }), + ) + + it( + 'should not cancel an ongoing fetch when refetch is called (cancelRefetch=true) if we do not have data yet', + withEffectRoot(async () => { + const key = ['test'] + let fetchCount = 0 + + const { promise, resolve } = promiseWithResolvers() + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: async () => { + fetchCount++ + return promise + }, + enabled: false, + }), + () => queryClient, + ) + + // Trigger two refetch close together + query.refetch() + query.refetch() + + resolve('resolved') + await promise + + expect(fetchCount).toBe(1) + }), + ) + + it( + 'should be able to watch a query without providing a query function', + withEffectRoot(async () => { + const key = ['test'] + const states: Array> = [] + + queryClient.setQueryDefaults(key, { + queryFn: () => 'data', + }) + + const query = createQuery( + () => ({ queryKey: key }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => { + expect(query.data).toBe('data') + }) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'data' }) + }), + ) + + it('should pick up a query when re-mounting with gcTime 0', async () => { + // this needs to be split into two different effect roots because + // effects won't pick up dependencies created after the first `await` + // -- the two roots effectively emulate two consecutive components being rendered + await withEffectRoot(async () => { + const { promise, resolve } = promiseWithResolvers() + + const query = createQuery( + () => ({ + queryKey: ['test'], + queryFn: () => promise, + gcTime: 0, + notifyOnChangeProps: 'all', + }), + () => queryClient, + ) + + expect(query).toMatchObject({ + isPending: true, + isSuccess: false, + isFetching: true, + }) + + resolve('resolved: 1') + await vi.waitFor(() => expect(query.data).toBe('resolved: 1')) + + expect(query).toMatchObject({ + isPending: false, + isSuccess: true, + isFetching: false, + }) + })() + + await withEffectRoot(async () => { + const { promise, resolve } = promiseWithResolvers() + + const query = createQuery( + () => ({ + queryKey: ['test'], + queryFn: () => promise, + gcTime: 0, + notifyOnChangeProps: 'all', + }), + () => queryClient, + ) + + expect(query).toMatchObject({ + data: 'resolved: 1', + isPending: false, + isSuccess: true, + isFetching: true, + }) + + resolve('resolved: 2') + await vi.waitFor(() => expect(query.data).toBe('resolved: 2')) + + expect(query).toMatchObject({ + data: 'resolved: 2', + isPending: false, + isSuccess: true, + isFetching: false, + }) + })() + }) + + it('should not get into an infinite loop when removing a query with gcTime 0 and rerendering', async () => { + const key = ['test'] + const states: Array> = [] + + // First mount: render the query and let it fetch + await withEffectRoot(async () => { + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve('data'), + gcTime: 0, + notifyOnChangeProps: ['isPending', 'isSuccess', 'data'], + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => { + expect(query.data).toBe('data') + }) + })() + + // Simulate rerender by removing the query and mounting again + await withEffectRoot(async () => { + queryClient.removeQueries({ queryKey: key }) + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve('data'), + gcTime: 0, + notifyOnChangeProps: ['isPending', 'isSuccess', 'data'], + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => { + expect(query.data).toBe('data') + }) + + // Give it time to catch any accidental infinite updates + await new Promise((r) => setTimeout(r, 100)) + })() + + expect(states.length).toBe(4) + expect(states[0]).toMatchObject({ + isPending: true, + isSuccess: false, + data: undefined, + }) + expect(states[1]).toMatchObject({ + isPending: false, + isSuccess: true, + data: 'data', + }) + expect(states[2]).toMatchObject({ + isPending: true, + isSuccess: false, + data: undefined, + }) + expect(states[3]).toMatchObject({ + isPending: false, + isSuccess: true, + data: 'data', + }) + }) + + it( + 'should fetch when refetchOnMount is false and nothing has been fetched yet', + withEffectRoot(async () => { + const key = ['test'] + const states: Array> = [] + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => 'test', + refetchOnMount: false, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => { + expect(query.data).toBe('test') + }) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }), + ) + + it( + 'should not fetch when refetchOnMount is false and data has been fetched already', + withEffectRoot(async () => { + const key = ['test'] + const states: Array> = [] + + queryClient.setQueryData(key, 'prefetched') + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => 'test', + refetchOnMount: false, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => { + expect(query.data).toBe('prefetched') + }) + + expect(states.length).toBe(1) + expect(states[0]).toMatchObject({ data: 'prefetched' }) + }), + ) + + it( + 'should be able to select a part of the data with select', + withEffectRoot(async () => { + const key = ['test'] + const states: Array> = [] + + const query = createQuery<{ name: string }, Error, string>( + () => ({ + queryKey: key, + queryFn: () => ({ name: 'test' }), + select: (data) => data.name, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => { + expect(query.data).toBe('test') + }) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }), + ) + + it( + 'should throw an error when a selector throws', + withEffectRoot(async () => { + const key = ['test'] + const error = new Error('Select Error') + const states: Array> = [] + + const query = createQuery<{ name: string }, Error, string>( + () => ({ + queryKey: key, + queryFn: () => ({ name: 'test' }), + select: () => { + throw error + }, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => { + expect(query.status).toBe('error') + }) + + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ status: 'pending', data: undefined }) + expect(states[1]).toMatchObject({ status: 'error', error }) + }), + ) + + it( + 'should be able to remove a query', + withEffectRoot(async () => { + const key = ['test'] + const states: Array> = [] + let count = 0 + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => ++count, + notifyOnChangeProps: 'all', + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => expect(query.data).toBe(1)) + + queryClient.removeQueries({ queryKey: key }) + await query.refetch() + + await vi.waitFor(() => expect(query.data).toBe(2)) + + expect(states.length).toBe(4) + expect(states[0]).toMatchObject({ + status: 'pending', + data: undefined, + dataUpdatedAt: 0, + }) + expect(states[1]).toMatchObject({ status: 'success', data: 1 }) + expect(states[2]).toMatchObject({ + status: 'pending', + data: undefined, + dataUpdatedAt: 0, + }) + expect(states[3]).toMatchObject({ status: 'success', data: 2 }) + }), + ) + + it( + 'keeps up-to-date with query key changes', + withEffectRoot(async () => { + let search = $state('') + const states: Array> = [] + + const query = createQuery( + () => ({ + queryKey: ['products', search], + queryFn: async () => Promise.resolve(search), + placeholderData: keepPreviousData, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => expect(query.data).toBe('')) + search = 'phone' + await vi.waitFor(() => expect(query.data).toBe('phone')) + + expect(states.length).toBe(4) + expect(states[0]).toMatchObject({ + status: 'pending', + fetchStatus: 'fetching', + data: undefined, + }) + expect(states[1]).toMatchObject({ + status: 'success', + fetchStatus: 'idle', + data: '', + }) + expect(states[2]).toMatchObject({ + status: 'success', + fetchStatus: 'fetching', + data: '', + }) + expect(states[3]).toMatchObject({ + status: 'success', + fetchStatus: 'idle', + data: 'phone', + }) + }), + ) + + it( + 'should create a new query when refetching a removed query', + withEffectRoot(async () => { + const key = ['test'] + const states: Array> = [] + let count = 0 + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve(++count), + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => { + expect(query.data).toBe(1) + }) + + queryClient.removeQueries({ queryKey: key }) + await query.refetch() + await vi.waitFor(() => { + expect(query.data).toBe(2) + }) + + expect(states.length).toBe(4) + // Initial + expect(states[0]).toMatchObject({ data: undefined, dataUpdatedAt: 0 }) + // Fetched + expect(states[1]).toMatchObject({ data: 1 }) + // Switch + expect(states[2]).toMatchObject({ data: undefined, dataUpdatedAt: 0 }) + // Fetched + expect(states[3]).toMatchObject({ data: 2 }) + }), + ) + + it( + 'should share equal data structures between query results', + withEffectRoot(async () => { + const key = ['test'] + + const result1 = [ + { id: '1', done: false }, + { id: '2', done: false }, + ] + + const result2 = [ + { id: '1', done: false }, + { id: '2', done: true }, + ] + + const states: Array> = [] + + let count = 0 + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => { + count++ + return Promise.resolve(count === 1 ? result1 : result2) + }, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => expect(query.data?.[1]?.done).toBe(false)) + await query.refetch() + await vi.waitFor(() => expect(query.data?.[1]?.done).toBe(true)) + + expect(states.length).toBe(4) + + const todos = states[1]?.data + const todo1 = todos?.[0] + const todo2 = todos?.[1] + + const newTodos = states[3]?.data + const newTodo1 = newTodos?.[0] + const newTodo2 = newTodos?.[1] + + expect(todos).toEqual(result1) + expect(newTodos).toEqual(result2) + expect(newTodos).not.toBe(todos) + expect(newTodo1).toBe(todo1) + expect(newTodo2).not.toBe(todo2) + }), + ) + + it( + 'should use query function from hook when the existing query does not have a query function', + withEffectRoot(async () => { + const key = ['test'] + + queryClient.setQueryData(key, 'set') + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve('fetched'), + initialData: 'initial', + staleTime: Infinity, + }), + () => queryClient, + ) + + await vi.waitFor(() => expect(query.data).toBe('set')) + queryClient.refetchQueries({ queryKey: key }) + await vi.waitFor(() => expect(query.data).toBe('fetched')) + }), + ) + + it( + 'should update query stale state and refetch when invalidated with invalidateQueries', + withEffectRoot(async () => { + const key = ['test'] + let count = 0 + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve(++count), + staleTime: Infinity, + }), + () => queryClient, + ) + + await vi.waitFor(() => + expect(query).toEqual( + expect.objectContaining({ + data: 1, + isStale: false, + isFetching: false, + }), + ), + ) + queryClient.invalidateQueries({ queryKey: key }) + await vi.waitFor(() => + expect(query).toEqual( + expect.objectContaining({ + data: 1, + isStale: true, + isFetching: true, + }), + ), + ) + await vi.waitFor(() => + expect(query).toEqual( + expect.objectContaining({ + data: 2, + isStale: false, + isFetching: false, + }), + ), + ) + }), + ) + + it( + 'should not update disabled query when refetching with refetchQueries', + withEffectRoot(async () => { + const key = ['test'] + const states: Array> = [] + let count = 0 + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve(++count), + enabled: false, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await sleep(50) + + expect(states.length).toBe(1) + expect(states[0]).toMatchObject({ + data: undefined, + isSuccess: false, + isFetching: false, + isStale: false, + }) + }), + ) + + it( + 'should not refetch disabled query when invalidated with invalidateQueries', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + let count = 0 + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve(++count), + enabled: false, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + queryClient.invalidateQueries({ queryKey: key }) + + // Wait long enough for the invalidation and potential refetch + await sleep(100) + + expect(states.length).toBe(1) + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: false, + isSuccess: false, + isStale: false, + }) + }), + ) + + it( + 'should not fetch when switching to a disabled query', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + let count = $state(0) + + const query = createQuery( + () => ({ + queryKey: [key, count], + queryFn: () => Promise.resolve(count), + enabled: count === 0, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + await vi.waitFor(() => expect(query.data).toBe(0)) + count = 1 + await vi.waitFor(() => expect(states.length).toBe(3)) + + // Fetch query + expect(states[0]).toMatchObject({ + isFetching: true, + isSuccess: false, + }) + // Fetched query + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + }) + // Switch to disabled query + expect(states[2]).toMatchObject({ + isFetching: false, + isSuccess: false, + }) + }), + ) + + it( + 'should keep the previous data when placeholderData is set', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + let count = $state(0) + + const query = createQuery( + () => ({ + queryKey: [key, count], + queryFn: () => Promise.resolve(count), + placeholderData: keepPreviousData, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // Wait for the initial fetch to complete + await vi.waitFor(() => expect(query.data).toBe(0)) + + // Update count to trigger a new fetch + count = 1 + + // Wait for all state updates to complete + await vi.waitFor(() => expect(states.length).toBe(4)) + + // Initial + expect(states[0]).toMatchObject({ + data: undefined, + isFetching: true, + isSuccess: false, + isPlaceholderData: false, + }) + // Fetched + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state + expect(states[2]).toMatchObject({ + data: 0, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // New data + expect(states[3]).toMatchObject({ + data: 1, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }), + ) + + it( + 'should not show initial data from next query if placeholderData is set', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + let count = $state(0) + + const query = createQuery( + () => ({ + queryKey: [key, count], + queryFn: () => Promise.resolve(count), + initialData: 99, + placeholderData: keepPreviousData, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // Wait for the initial fetch to complete + await vi.waitFor(() => expect(query.data).toBe(0)) + + // Update count to trigger a new fetch + count = 1 + + // Wait for the new fetch to complete + await vi.waitFor(() => expect(query.data).toBe(1)) + + // Wait for all state updates to complete + await vi.waitFor(() => expect(states.length).toBe(4)) + + // Initial + expect(states[0]).toMatchObject({ + data: 99, + isFetching: true, + isSuccess: true, + isPlaceholderData: false, + }) + // Fetched + expect(states[1]).toMatchObject({ + data: 0, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state + expect(states[2]).toMatchObject({ + data: 99, + isFetching: true, + isSuccess: true, + isPlaceholderData: false, + }) + // New data + expect(states[3]).toMatchObject({ + data: 1, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }), + ) + + it( + 'should keep the previous data on disabled query when placeholderData is set and switching query key multiple times', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + + // Set initial query data + queryClient.setQueryData([key, 10], 10) + + let count = $state(10) + + const query = createQuery( + () => ({ + queryKey: [key, count], + queryFn: () => Promise.resolve(count), + enabled: false, + placeholderData: keepPreviousData, + notifyOnChangeProps: 'all', + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // let that effect ^ run to push the initial state + flushSync() + flushSync(() => (count = 11)) + flushSync(() => (count = 12)) + await query.refetch() + // Wait for all operations to complete + await vi.waitFor(() => expect(query.data).toBe(12)) + + // Disabled query + expect(states[0]).toMatchObject({ + data: 10, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + // Set state (11) + expect(states[1]).toMatchObject({ + data: 10, + isFetching: false, + isSuccess: true, + isPlaceholderData: true, + }) + // Set state (12) + expect(states[2]).toMatchObject({ + data: 10, + isFetching: false, + isSuccess: true, + isPlaceholderData: true, + }) + // Refetch + expect(states[3]).toMatchObject({ + data: 10, + isFetching: true, + isSuccess: true, + isPlaceholderData: true, + }) + // Refetch done + expect(states[4]).toMatchObject({ + data: 12, + isFetching: false, + isSuccess: true, + isPlaceholderData: false, + }) + }), + ) + + it( + 'should use the correct query function when components use different configurations', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + const { promise, resolve } = promiseWithResolvers() + + // Simulate FirstComponent + const firstQuery = createQuery( + () => ({ + queryKey: key, + queryFn: () => promise, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...firstQuery }) + }) + + // Simulate SecondComponent + createQuery( + () => ({ + queryKey: key, + queryFn: () => 2, + }), + () => queryClient, + ) + + // Resolve the first query + resolve(1) + + // Wait for the first query to complete + await vi.waitFor(() => expect(firstQuery.data).toBe(1)) + + // Refetch the first query + await firstQuery.refetch() + + // Wait for all state updates to complete + await vi.waitFor(() => expect(states.length).toBe(4)) + + expect(states[0]).toMatchObject({ + data: undefined, + }) + expect(states[1]).toMatchObject({ + data: 1, + }) + expect(states[2]).toMatchObject({ + data: 1, + }) + // This state should be 1 instead of 2 + expect(states[3]).toMatchObject({ + data: 1, + }) + }), + ) + + it.todo( + 'should be able to set different stale times for a query', + async () => { + /** + * TODO: There's a super weird bug with this test, and I think it's caused by a race condition in query-core. + * + * If you add this to the top `updateResult` in `packages/query-core/src/queryObserver.ts:647`: + * ``` + * for (let i = 0; i < 10_000_000; i++) { + * continue + * } + * ``` + * + * This test will miraculously start to pass. I'm suspicious that there's some race condition between props + * being tracked and `updateResult` being called, but that _should_ be fixed by `notifyOnChangeProps: 'all'`, + * and that's not doing anything. + * + * This test will also start to magically pass if you put `$inspect(firstQuery)` before `vi.waitFor` near + * the end of the test. + */ + + const key = ['test-key'] + const states1: Array> = [] + const states2: Array> = [] + + // Prefetch the query + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + return 'prefetch' + }, + }) + + await vi.waitFor(() => + expect(queryClient.getQueryState(key)?.data).toBe('prefetch'), + ) + + await withEffectRoot(async () => { + const firstQuery = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve('one'), + staleTime: 100, + }), + () => queryClient, + ) + + $effect(() => { + states1.push({ ...firstQuery }) + }) + + const secondQuery = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve('two'), + staleTime: 10, + }), + () => queryClient, + ) + + $effect(() => { + states2.push({ ...secondQuery }) + }) + + await vi.waitFor(() => { + expect(firstQuery).toMatchObject({ data: 'two', isStale: true }) + expect(secondQuery).toMatchObject({ data: 'two', isStale: true }) + }) + + expect(states1).toMatchObject([ + // First render + { + data: 'prefetch', + isStale: false, + }, + // Second createQuery started fetching + { + data: 'prefetch', + isStale: false, + }, + // Second createQuery data came in + { + data: 'two', + isStale: false, + }, + // Data became stale after 100ms + { + data: 'two', + isStale: true, + }, + ]) + + expect(states2).toMatchObject([ + // First render, data is stale and starts fetching + { + data: 'prefetch', + isStale: true, + }, + // Second createQuery data came in + { + data: 'two', + isStale: false, + }, + // Data became stale after 10ms + { + data: 'two', + isStale: true, + }, + ]) + })() + }, + ) + + it( + 'should re-render when a query becomes stale', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => 'test', + staleTime: 50, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // Wait for the query to become stale + await sleep(100) + + expect(states.length).toBe(3) + expect(states[0]).toMatchObject({ isStale: true }) + expect(states[1]).toMatchObject({ isStale: false }) + expect(states[2]).toMatchObject({ isStale: true }) + }), + ) + + it( + 'should not re-render when it should only re-render on data changes and the data did not change', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + const { promise, resolve } = promiseWithResolvers() + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => promise, + notifyOnChangeProps: ['data'], + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + resolve('test') + + // Refetch the query + setTimeout(() => { + query.refetch() + }, 10) + + await vi.waitFor(() => { + expect(states.length).toBe(2) + }) + + expect(states[0]).toMatchObject({ + data: undefined, + status: 'pending', + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'test', + status: 'success', + isFetching: false, + }) + }), + ) + + it( + 'should track properties and only re-render when a tracked property changes', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array = [] + const { promise, resolve } = promiseWithResolvers() + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => promise, + }), + () => queryClient, + ) + + $effect(() => { + states.push(query.data) + }) + + // Resolve the promise after a delay + setTimeout(() => { + resolve('test') + }, 10) + + await vi.waitFor(() => expect(query.data).toBe('test')) + + // Refetch after data is available + setTimeout(() => { + if (query.data) { + query.refetch() + } + }, 20) + + // Wait for refetch to complete + await sleep(30) + + expect(states.length).toBe(2) + expect(states[0]).toBe(undefined) + expect(states[1]).toBe('test') + }), + ) + + it( + 'should always re-render if we are tracking props but not using any', + withEffectRoot(async () => { + const key = ['test-key'] + let renderCount = 0 + const states: Array> = [] + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve('test'), + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // Track changes to the query state + $effect(() => { + // @ts-expect-error + const _ = { ...query } + renderCount++ + }) + + await vi.waitFor(() => expect(query.data).toBe('test')) + + expect(renderCount).toBe(2) + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined }) + expect(states[1]).toMatchObject({ data: 'test' }) + }), + ) + + it( + 'should update query options', + withEffectRoot(() => { + const key = ['test-key'] + + const queryFn = async () => { + await sleep(10) + return 'data1' + } + + // Create two queries with the same key but different options + createQuery( + () => ({ queryKey: key, queryFn, retryDelay: 10 }), + () => queryClient, + ) + + createQuery( + () => ({ queryKey: key, queryFn, retryDelay: 20 }), + () => queryClient, + ) + + // The last options should win + expect(queryCache.find({ queryKey: key })!.options.retryDelay).toBe(20) + }), + ) + + it( + 'should start with status pending, fetchStatus idle if enabled is false', + withEffectRoot(async () => { + const key1 = ['test-key-1'] + const key2 = ['test-key-2'] + const states1: Array> = [] + const states2: Array> = [] + + const query1 = createQuery( + () => ({ + queryKey: key1, + queryFn: () => 'data', + enabled: false, + }), + () => queryClient, + ) + + const query2 = createQuery( + () => ({ + queryKey: key2, + queryFn: () => 'data', + }), + () => queryClient, + ) + + $effect(() => { + states1.push({ ...query1 }) + }) + + $effect(() => { + states2.push({ ...query2 }) + }) + + // Check initial states + expect(query1.status).toBe('pending') + expect(query1.fetchStatus).toBe('idle') + + // Wait for second query to complete + await vi.waitFor(() => { + expect(query2.status).toBe('success') + expect(query2.fetchStatus).toBe('idle') + }) + + // Verify the state transitions for the second query + expect(states2[0]?.status).toBe('pending') + expect(states2[0]?.fetchStatus).toBe('fetching') + }), + ) + + it( + 'should be in "pending" state by default', + withEffectRoot(() => { + const key = ['test-key'] + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => new Promise(() => {}), + }), + () => queryClient, + ) + + expect(query.status).toBe('pending') + }), + ) + + it( + 'should not refetch query on focus when `enabled` is set to `false`', + withEffectRoot(async () => { + const key = ['test-key'] + const queryFn = vi.fn().mockReturnValue('data') + + const query = createQuery( + () => ({ + queryKey: key, + queryFn, + enabled: false, + }), + () => queryClient, + ) + + // Wait a bit to ensure the query has time to settle + await sleep(10) + + // Simulate window focus + window.dispatchEvent(new Event('visibilitychange')) + + // Wait a bit more to ensure no refetch happens + await sleep(10) + + // The query function should not have been called + expect(queryFn).not.toHaveBeenCalled() + + // Data should be undefined since the query is disabled + expect(query.data).toBeUndefined() + }), + ) + + it( + 'should not refetch stale query on focus when `refetchOnWindowFocus` is set to `false`', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + let count = 0 + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => count++, + staleTime: 0, + refetchOnWindowFocus: false, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // Wait for the initial fetch to complete + await vi.waitFor(() => expect(query.data).toBe(0)) + + // Simulate window focus + window.dispatchEvent(new Event('visibilitychange')) + + // Wait a bit to ensure no refetch happens + await sleep(10) + + // Should only have 2 states: initial and after fetch + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined, isFetching: true }) + expect(states[1]).toMatchObject({ data: 0, isFetching: false }) + + // Count should still be 0 since no refetch occurred + expect(count).toBe(1) + }), + ) + + it( + 'should not refetch stale query on focus when `refetchOnWindowFocus` is set to a function that returns `false`', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + let count = 0 + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => count++, + staleTime: 0, + refetchOnWindowFocus: () => false, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // Wait for the initial fetch to complete + await vi.waitFor(() => expect(query.data).toBe(0)) + + // Simulate window focus + window.dispatchEvent(new Event('visibilitychange')) + + // Wait a bit to ensure no refetch happens + await sleep(10) + + // Should only have 2 states: initial and after fetch + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined, isFetching: true }) + expect(states[1]).toMatchObject({ data: 0, isFetching: false }) + + // Count should still be 0 since no refetch occurred + expect(count).toBe(1) + }), + ) + + it( + 'should not refetch fresh query on focus when `refetchOnWindowFocus` is set to `true`', + withEffectRoot(async () => { + const key = ['test-key'] + const states: Array> = [] + let count = 0 + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => count++, + staleTime: Infinity, + refetchOnWindowFocus: true, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // Wait for the initial fetch to complete + await vi.waitFor(() => expect(query.data).toBe(0)) + + // Simulate window focus + window.dispatchEvent(new Event('visibilitychange')) + + // Wait a bit to ensure no refetch happens + await sleep(10) + + // Should only have 2 states: initial and after fetch + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ data: undefined, isFetching: true }) + expect(states[1]).toMatchObject({ data: 0, isFetching: false }) + + // Count should still be 0 since no refetch occurred + expect(count).toBe(1) + }), + ) + + it('should refetch fresh query when refetchOnMount is set to always', async () => { + const key = ['test-key'] + const states: Array> = [] + + // Prefetch the query + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => 'prefetched', + }) + + await withEffectRoot(async () => { + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => 'data', + refetchOnMount: 'always', + staleTime: Infinity, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // Wait for the refetch to complete + await vi.waitFor(() => expect(query.data).toBe('data')) + + // Should have 2 states: initial (with prefetched data) and after refetch + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: 'prefetched', + isStale: false, + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'data', + isStale: false, + isFetching: false, + }) + })() + }) + + it('should refetch stale query when refetchOnMount is set to true', async () => { + const key = ['test-key'] + const states: Array> = [] + + // Prefetch the query + await queryClient.prefetchQuery({ + queryKey: key, + queryFn: () => 'prefetched', + }) + + await withEffectRoot(async () => { + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => 'data', + refetchOnMount: true, + staleTime: 0, + }), + () => queryClient, + ) + + $effect(() => { + states.push({ ...query }) + }) + + // Wait for the refetch to complete + await vi.waitFor(() => expect(query.data).toBe('data')) + + // Should have 2 states: initial (with prefetched data) and after refetch + expect(states.length).toBe(2) + expect(states[0]).toMatchObject({ + data: 'prefetched', + isStale: true, + isFetching: true, + }) + expect(states[1]).toMatchObject({ + data: 'data', + isStale: true, + isFetching: false, + }) + })() + }) + + it( + 'should set status to error if queryFn throws', + withEffectRoot(async () => { + const key = ['test-key'] // Declare key variable + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.reject(new Error('Error test')), + retry: false, + }), + () => queryClient, + ) + + await vi.waitFor(() => expect(query.status).toBe('error')) + expect(query.error?.message).toBe('Error test') + + consoleMock.mockRestore() + }), + ) + + it( + 'should set status to error instead of throwing when error should not be thrown', + withEffectRoot(async () => { + const key = ['test-key'] // Declare key variable + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.reject(new Error('Local Error')), + retry: false, + throwOnError: (err) => err.message !== 'Local Error', + }), + () => queryClient, + ) + + await vi.waitFor(() => expect(query.status).toBe('error')) + expect(query.error?.message).toBe('Local Error') + }), + ) + + it( + 'should support changing provided query client', + withEffectRoot(async () => { + const queryClient1 = new QueryClient() + const queryClient2 = new QueryClient() + + let queryClient = $state(queryClient1) + + const key = ['test'] + + createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve('prefetched'), + }), + () => queryClient, + ) + + expect(queryClient1.getQueryCache().find({ queryKey: key })).toBeDefined() + + queryClient = queryClient2 + flushSync() + + expect(queryClient2.getQueryCache().find({ queryKey: key })).toBeDefined() + }), + ) +}) diff --git a/packages/svelte-query/tests/createQuery.test-d.ts b/packages/svelte-query/tests/createQuery.test-d.ts new file mode 100644 index 0000000000..5ede74c49d --- /dev/null +++ b/packages/svelte-query/tests/createQuery.test-d.ts @@ -0,0 +1,88 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createQuery, queryOptions } from '../src/index.js' + +describe('initialData', () => { + describe('Config object overload', () => { + it('TData should always be defined when initialData is provided as an object', () => { + const { data } = createQuery(() => ({ + queryKey: ['key'], + queryFn: () => ({ wow: true }), + initialData: { wow: true }, + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('TData should be defined when passed through queryOptions', () => { + const options = queryOptions({ + queryKey: ['key'], + queryFn: () => ({ wow: true }), + initialData: { wow: true }, + }) + const { data } = createQuery(() => options) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('TData should have undefined in the union when initialData is NOT provided', () => { + const { data } = createQuery(() => ({ + queryKey: ['key'], + queryFn: () => ({ wow: true }), + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + + it('TData should have undefined in the union when initialData is provided as a function which can return undefined', () => { + const { data } = createQuery(() => ({ + queryKey: ['key'], + queryFn: () => ({ wow: true }), + initialData: () => undefined as { wow: boolean } | undefined, + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + }) + + describe('Query key overload', () => { + it('TData should always be defined when initialData is provided', () => { + const { data } = createQuery(() => ({ + queryKey: ['key'], + queryFn: () => ({ wow: true }), + initialData: { wow: true }, + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('TData should have undefined in the union when initialData is NOT provided', () => { + const { data } = createQuery(() => ({ + queryKey: ['key'], + queryFn: () => ({ wow: true }), + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + }) + + describe('Query key and func', () => { + it('TData should always be defined when initialData is provided', () => { + const { data } = createQuery(() => ({ + queryKey: ['key'], + queryFn: () => ({ wow: true }), + initialData: { wow: true }, + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean }>() + }) + + it('TData should have undefined in the union when initialData is NOT provided', () => { + const { data } = createQuery(() => ({ + queryKey: ['key'], + queryFn: () => ({ wow: true }), + })) + + expectTypeOf(data).toEqualTypeOf<{ wow: boolean } | undefined>() + }) + }) +}) diff --git a/packages/svelte-query/tests/createQuery/BaseExample.svelte b/packages/svelte-query/tests/createQuery/BaseExample.svelte deleted file mode 100644 index d7a824c26b..0000000000 --- a/packages/svelte-query/tests/createQuery/BaseExample.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -
Status: {$query.status}
-
Failure Count: {$query.failureCount}
-
Data: {$query.data ?? 'undefined'}
diff --git a/packages/svelte-query/tests/createQuery/DisabledExample.svelte b/packages/svelte-query/tests/createQuery/DisabledExample.svelte deleted file mode 100644 index 0ee100efad..0000000000 --- a/packages/svelte-query/tests/createQuery/DisabledExample.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - -
Data: {$query.data ?? 'undefined'}
-
Count: {$count}
diff --git a/packages/svelte-query/tests/createQuery/PlaceholderData.svelte b/packages/svelte-query/tests/createQuery/PlaceholderData.svelte deleted file mode 100644 index 6be8b5314e..0000000000 --- a/packages/svelte-query/tests/createQuery/PlaceholderData.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - - -
Status: {$query.status}
-
Data: {$query.data ?? 'undefined'}
diff --git a/packages/svelte-query/tests/createQuery/RefetchExample.svelte b/packages/svelte-query/tests/createQuery/RefetchExample.svelte deleted file mode 100644 index 9e89b8de37..0000000000 --- a/packages/svelte-query/tests/createQuery/RefetchExample.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - - -
Data: {$query.data ?? 'undefined'}
diff --git a/packages/svelte-query/tests/createQuery/createQuery.test-d.ts b/packages/svelte-query/tests/createQuery/createQuery.test-d.ts deleted file mode 100644 index 7936758401..0000000000 --- a/packages/svelte-query/tests/createQuery/createQuery.test-d.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expectTypeOf, test } from 'vitest' -import { get } from 'svelte/store' -import { createQuery, queryOptions } from '../../src/index.js' -import type { OmitKeyof } from '@tanstack/query-core' -import type { CreateQueryOptions } from '../../src/index.js' - -describe('createQuery', () => { - test('TData should always be defined when initialData is provided as an object', () => { - const query = createQuery({ - queryKey: ['key'], - queryFn: () => ({ wow: true }), - initialData: { wow: true }, - }) - - expectTypeOf(get(query).data).toEqualTypeOf<{ wow: boolean }>() - }) - - test('TData should be defined when passed through queryOptions', () => { - const options = queryOptions({ - queryKey: ['key'], - queryFn: () => ({ wow: true }), - initialData: { wow: true }, - }) - const query = createQuery(options) - - expectTypeOf(get(query).data).toEqualTypeOf<{ wow: boolean }>() - }) - - test('TData should always be defined when initialData is provided as a function which ALWAYS returns the data', () => { - const query = createQuery({ - queryKey: ['key'], - queryFn: () => ({ wow: true }), - initialData: () => ({ wow: true }), - }) - - expectTypeOf(get(query).data).toEqualTypeOf<{ wow: boolean }>() - }) - - test('TData should have undefined in the union when initialData is NOT provided', () => { - const query = createQuery({ - queryKey: ['key'], - queryFn: () => { - return { - wow: true, - } - }, - }) - - expectTypeOf(get(query).data).toEqualTypeOf<{ wow: boolean } | undefined>() - }) - - test('Allow custom hooks using CreateQueryOptions', () => { - type Data = string - - const useCustomQuery = ( - options?: OmitKeyof, 'queryKey' | 'queryFn'>, - ) => { - return createQuery({ - ...options, - queryKey: ['todos-key'], - queryFn: () => Promise.resolve('data'), - }) - } - - const query = useCustomQuery() - - expectTypeOf(get(query).data).toEqualTypeOf() - }) -}) diff --git a/packages/svelte-query/tests/createQuery/createQuery.test.ts b/packages/svelte-query/tests/createQuery/createQuery.test.ts deleted file mode 100644 index f213a16251..0000000000 --- a/packages/svelte-query/tests/createQuery/createQuery.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { fireEvent, render } from '@testing-library/svelte' -import { derived, get, writable } from 'svelte/store' -import { QueryClient } from '@tanstack/query-core' -import { sleep } from '@tanstack/query-test-utils' -import BaseExample from './BaseExample.svelte' -import DisabledExample from './DisabledExample.svelte' -import PlaceholderData from './PlaceholderData.svelte' -import RefetchExample from './RefetchExample.svelte' -import type { Writable } from 'svelte/store' -import type { QueryObserverResult } from '@tanstack/query-core' - -describe('createQuery', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - test('Return the correct states for a successful query', async () => { - const statesStore: Writable> = writable([]) - - const options = { - queryKey: ['test'], - queryFn: () => sleep(10).then(() => 'Success'), - } - - const rendered = render(BaseExample, { - props: { - options, - queryClient: new QueryClient(), - states: statesStore, - }, - }) - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Status: success')).toBeInTheDocument() - - const states = get(statesStore) - - expect(states).toHaveLength(2) - expect(states[0]).toMatchObject({ - data: undefined, - dataUpdatedAt: 0, - error: null, - errorUpdatedAt: 0, - failureCount: 0, - failureReason: null, - errorUpdateCount: 0, - isError: false, - isFetched: false, - isFetchedAfterMount: false, - isFetching: true, - isPaused: false, - isPending: true, - isInitialLoading: true, - isLoading: true, - isLoadingError: false, - isPlaceholderData: false, - isRefetchError: false, - isRefetching: false, - isStale: true, - isSuccess: false, - refetch: expect.any(Function), - status: 'pending', - fetchStatus: 'fetching', - }) - expect(states[1]).toMatchObject({ - data: 'Success', - dataUpdatedAt: expect.any(Number), - error: null, - errorUpdatedAt: 0, - failureCount: 0, - failureReason: null, - errorUpdateCount: 0, - isError: false, - isFetched: true, - isFetchedAfterMount: true, - isFetching: false, - isPaused: false, - isPending: false, - isInitialLoading: false, - isLoading: false, - isLoadingError: false, - isPlaceholderData: false, - isRefetchError: false, - isRefetching: false, - isStale: true, - isSuccess: true, - refetch: expect.any(Function), - status: 'success', - fetchStatus: 'idle', - }) - }) - - test('Return the correct states for an unsuccessful query', async () => { - const statesStore: Writable> = writable([]) - - const options = { - queryKey: ['test'], - queryFn: () => - sleep(10).then(() => Promise.reject(new Error('Rejected'))), - retry: 1, - retryDelay: 10, - } - - const rendered = render(BaseExample, { - props: { - options, - queryClient: new QueryClient(), - states: statesStore, - }, - }) - - await vi.advanceTimersByTimeAsync(31) - expect(rendered.getByText('Status: error')).toBeInTheDocument() - - const states = get(statesStore) - - expect(states).toHaveLength(3) - expect(states[0]).toMatchObject({ - data: undefined, - dataUpdatedAt: 0, - error: null, - errorUpdatedAt: 0, - failureCount: 0, - failureReason: null, - errorUpdateCount: 0, - isError: false, - isFetched: false, - isFetchedAfterMount: false, - isFetching: true, - isPaused: false, - isPending: true, - isInitialLoading: true, - isLoading: true, - isLoadingError: false, - isPlaceholderData: false, - isRefetchError: false, - isRefetching: false, - isStale: true, - isSuccess: false, - refetch: expect.any(Function), - status: 'pending', - fetchStatus: 'fetching', - }) - expect(states[1]).toMatchObject({ - data: undefined, - dataUpdatedAt: 0, - error: null, - errorUpdatedAt: 0, - failureCount: 1, - failureReason: new Error('Rejected'), - errorUpdateCount: 0, - isError: false, - isFetched: false, - isFetchedAfterMount: false, - isFetching: true, - isPaused: false, - isPending: true, - isInitialLoading: true, - isLoading: true, - isLoadingError: false, - isPlaceholderData: false, - isRefetchError: false, - isRefetching: false, - isStale: true, - isSuccess: false, - refetch: expect.any(Function), - status: 'pending', - fetchStatus: 'fetching', - }) - expect(states[2]).toMatchObject({ - data: undefined, - dataUpdatedAt: 0, - error: new Error('Rejected'), - errorUpdatedAt: expect.any(Number), - failureCount: 2, - failureReason: new Error('Rejected'), - errorUpdateCount: 1, - isError: true, - isFetched: true, - isFetchedAfterMount: true, - isFetching: false, - isPaused: false, - isPending: false, - isInitialLoading: false, - isLoading: false, - isLoadingError: true, - isPlaceholderData: false, - isRefetchError: false, - isRefetching: false, - isStale: true, - isSuccess: false, - refetch: expect.any(Function), - status: 'error', - fetchStatus: 'idle', - }) - }) - - test('Accept a writable store for options', async () => { - const statesStore: Writable> = writable([]) - - const optionsStore = writable({ - queryKey: ['test'], - queryFn: () => sleep(10).then(() => 'Success'), - }) - - const rendered = render(BaseExample, { - props: { - options: optionsStore, - queryClient: new QueryClient(), - states: statesStore, - }, - }) - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Status: success')).toBeInTheDocument() - }) - - test('Accept a derived store for options', async () => { - const statesStore: Writable> = writable([]) - - const writableStore = writable('test') - - const derivedStore = derived(writableStore, ($store) => ({ - queryKey: [$store], - queryFn: () => sleep(10).then(() => 'Success'), - })) - - const rendered = render(BaseExample, { - props: { - options: derivedStore, - queryClient: new QueryClient(), - states: statesStore, - }, - }) - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.queryByText('Status: success')).toBeInTheDocument() - }) - - test('Ensure reactivity when queryClient defaults are set', async () => { - const statesStore: Writable> = writable([]) - - const writableStore = writable(1) - - const derivedStore = derived(writableStore, ($store) => ({ - queryKey: [$store], - queryFn: () => sleep(10).then(() => $store), - })) - - const rendered = render(BaseExample, { - props: { - options: derivedStore, - queryClient: new QueryClient({ - defaultOptions: { queries: { staleTime: 60 * 1000 } }, - }), - states: statesStore, - }, - }) - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Data: 1')).toBeInTheDocument() - expect(rendered.queryByText('Data: 2')).not.toBeInTheDocument() - - writableStore.set(2) - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.queryByText('Data: 1')).not.toBeInTheDocument() - expect(rendered.getByText('Data: 2')).toBeInTheDocument() - - writableStore.set(1) - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Data: 1')).toBeInTheDocument() - expect(rendered.queryByText('Data: 2')).not.toBeInTheDocument() - }) - - test('Keep previous data when placeholderData is set', async () => { - const statesStore: Writable> = writable([]) - - const rendered = render(PlaceholderData, { - props: { - queryClient: new QueryClient(), - states: statesStore, - }, - }) - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Data: 0')).toBeInTheDocument() - - fireEvent.click(rendered.getByRole('button', { name: 'setCount' })) - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Data: 1')).toBeInTheDocument() - - const states = get(statesStore) - - expect(states).toHaveLength(4) - - // Initial - expect(states[0]).toMatchObject({ - data: undefined, - isFetching: true, - isSuccess: false, - isPlaceholderData: false, - }) - // Fetched - expect(states[1]).toMatchObject({ - data: 0, - isFetching: false, - isSuccess: true, - isPlaceholderData: false, - }) - // Set state - expect(states[2]).toMatchObject({ - data: 0, - isFetching: true, - isSuccess: true, - isPlaceholderData: true, - }) - // New data - expect(states[3]).toMatchObject({ - data: 1, - isFetching: false, - isSuccess: true, - isPlaceholderData: false, - }) - }) - - test('Should not fetch when switching to a disabled query', async () => { - const statesStore: Writable> = writable([]) - - const rendered = render(DisabledExample, { - props: { - states: statesStore, - }, - }) - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Data: 0')).toBeInTheDocument() - - fireEvent.click(rendered.getByRole('button', { name: /Increment/i })) - await vi.advanceTimersByTimeAsync(0) - expect(rendered.getByText('Count: 1')).toBeInTheDocument() - expect(rendered.getByText('Data: undefined')).toBeInTheDocument() - - const states = get(statesStore) - - expect(states).toHaveLength(4) - - // Fetch query - expect(states[0]).toMatchObject({ - data: undefined, - isFetching: true, - isSuccess: false, - }) - // Fetched query - expect(states[1]).toMatchObject({ - data: 0, - isFetching: false, - isSuccess: true, - }) - // Switch to query disable - expect(states[2]).toMatchObject({ - data: undefined, - isFetching: false, - isSuccess: false, - }) - // Fetched disabled query - expect(states[3]).toMatchObject({ - data: undefined, - isFetching: false, - isSuccess: false, - }) - }) - - test('Create a new query when refetching a removed query', async () => { - const statesStore: Writable> = writable([]) - - const rendered = render(RefetchExample, { - props: { - states: statesStore, - }, - }) - - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Data: 1')).toBeInTheDocument() - - fireEvent.click(rendered.getByRole('button', { name: /Remove/i })) - fireEvent.click(rendered.getByRole('button', { name: /Refetch/i })) - await vi.advanceTimersByTimeAsync(11) - expect(rendered.getByText('Data: 2')).toBeInTheDocument() - - const states = get(statesStore) - - expect(states.length).toBe(4) - // Initial - expect(states[0]).toMatchObject({ data: undefined, dataUpdatedAt: 0 }) - // Fetched - expect(states[1]).toMatchObject({ data: 1 }) - // Switch - expect(states[2]).toMatchObject({ data: undefined, dataUpdatedAt: 0 }) - // Fetched - expect(states[3]).toMatchObject({ data: 2 }) - }) -}) diff --git a/packages/svelte-query/tests/infiniteQueryOptions/infiniteQueryOptions.test-d.ts b/packages/svelte-query/tests/infiniteQueryOptions/infiniteQueryOptions.test-d.ts index 45f98dd085..8cec61e6ea 100644 --- a/packages/svelte-query/tests/infiniteQueryOptions/infiniteQueryOptions.test-d.ts +++ b/packages/svelte-query/tests/infiniteQueryOptions/infiniteQueryOptions.test-d.ts @@ -1,5 +1,4 @@ import { describe, expectTypeOf, test } from 'vitest' -import { get } from 'svelte/store' import { QueryClient } from '@tanstack/query-core' import { createInfiniteQuery, infiniteQueryOptions } from '../../src/index.js' import type { InfiniteData } from '@tanstack/query-core' @@ -37,10 +36,10 @@ describe('queryOptions', () => { initialPageParam: 1, }) - const query = createInfiniteQuery(options) + const query = createInfiniteQuery(() => options) // known issue: type of pageParams is unknown when returned from useInfiniteQuery - expectTypeOf(get(query).data).toEqualTypeOf< + expectTypeOf(query.data).toEqualTypeOf< InfiniteData | undefined >() }) diff --git a/packages/svelte-query/tests/queryOptions/queryOptions.test-d.ts b/packages/svelte-query/tests/queryOptions/queryOptions.test-d.ts index 8562a7c227..1dc175f61e 100644 --- a/packages/svelte-query/tests/queryOptions/queryOptions.test-d.ts +++ b/packages/svelte-query/tests/queryOptions/queryOptions.test-d.ts @@ -1,5 +1,4 @@ import { describe, expectTypeOf, test } from 'vitest' -import { get } from 'svelte/store' import { QueriesObserver, QueryClient, @@ -46,11 +45,11 @@ describe('queryOptions', () => { queryFn: () => Promise.resolve(5), }) - const queries = createQueries({ + const queries = createQueries(() => ({ queries: [options], - }) + })) - expectTypeOf(get(queries)[0].data).toEqualTypeOf() + expectTypeOf(queries[0].data).toEqualTypeOf() }) test('Should tag the queryKey with the result type of the QueryFn', () => { diff --git a/packages/svelte-query/tests/useIsFetching/BaseExample.svelte b/packages/svelte-query/tests/useIsFetching/BaseExample.svelte index 844d60bf80..522955c79b 100644 --- a/packages/svelte-query/tests/useIsFetching/BaseExample.svelte +++ b/packages/svelte-query/tests/useIsFetching/BaseExample.svelte @@ -1,24 +1,11 @@ - + + -
isFetching: {$isFetching}
-
Data: {$query.data ?? 'undefined'}
+ +
diff --git a/packages/svelte-query/tests/useIsFetching/FetchStatus.svelte b/packages/svelte-query/tests/useIsFetching/FetchStatus.svelte new file mode 100644 index 0000000000..5b10705709 --- /dev/null +++ b/packages/svelte-query/tests/useIsFetching/FetchStatus.svelte @@ -0,0 +1,6 @@ + + +
isFetching: {isFetching.current}
diff --git a/packages/svelte-query/tests/useIsFetching/Query.svelte b/packages/svelte-query/tests/useIsFetching/Query.svelte new file mode 100644 index 0000000000..3a2eeb669e --- /dev/null +++ b/packages/svelte-query/tests/useIsFetching/Query.svelte @@ -0,0 +1,19 @@ + + + + +
Data: {query.data ?? 'undefined'}
diff --git a/packages/svelte-query/tests/useIsFetching/useIsFetching.test.ts b/packages/svelte-query/tests/useIsFetching/useIsFetching.svelte.test.ts similarity index 100% rename from packages/svelte-query/tests/useIsFetching/useIsFetching.test.ts rename to packages/svelte-query/tests/useIsFetching/useIsFetching.svelte.test.ts diff --git a/packages/svelte-query/tests/useIsMutating/BaseExample.svelte b/packages/svelte-query/tests/useIsMutating/BaseExample.svelte index e2c7523425..e2e46622c7 100644 --- a/packages/svelte-query/tests/useIsMutating/BaseExample.svelte +++ b/packages/svelte-query/tests/useIsMutating/BaseExample.svelte @@ -1,20 +1,11 @@ - + + -
isMutating: {$isMutating}
+ +
diff --git a/packages/svelte-query/tests/useIsMutating/MutatingStatus.svelte b/packages/svelte-query/tests/useIsMutating/MutatingStatus.svelte new file mode 100644 index 0000000000..a747ed8326 --- /dev/null +++ b/packages/svelte-query/tests/useIsMutating/MutatingStatus.svelte @@ -0,0 +1,6 @@ + + +
isMutating: {isMutating.current}
diff --git a/packages/svelte-query/tests/useIsMutating/Query.svelte b/packages/svelte-query/tests/useIsMutating/Query.svelte new file mode 100644 index 0000000000..f9cc2504b0 --- /dev/null +++ b/packages/svelte-query/tests/useIsMutating/Query.svelte @@ -0,0 +1,14 @@ + + + diff --git a/packages/svelte-query/tests/useIsMutating/useIsMutating.test.ts b/packages/svelte-query/tests/useIsMutating/useIsMutating.svelte.test.ts similarity index 100% rename from packages/svelte-query/tests/useIsMutating/useIsMutating.test.ts rename to packages/svelte-query/tests/useIsMutating/useIsMutating.svelte.test.ts diff --git a/packages/svelte-query/tests/useMutationState/BaseExample.svelte b/packages/svelte-query/tests/useMutationState/BaseExample.svelte index 8ac3be9da3..245fdda645 100644 --- a/packages/svelte-query/tests/useMutationState/BaseExample.svelte +++ b/packages/svelte-query/tests/useMutationState/BaseExample.svelte @@ -6,13 +6,20 @@ useMutationState, } from '../../src/index.js' import type { + Accessor, CreateMutationOptions, MutationStateOptions, - } from '../../src/types.js' + } from '../../src/index.js' - export let successMutationOpts: CreateMutationOptions - export let errorMutationOpts: CreateMutationOptions - export let mutationStateOpts: MutationStateOptions | undefined = undefined + let { + successMutationOpts, + errorMutationOpts, + mutationStateOpts, + }: { + successMutationOpts: Accessor + errorMutationOpts: Accessor + mutationStateOpts?: MutationStateOptions | undefined + } = $props() const queryClient = new QueryClient() setQueryClientContext(queryClient) @@ -23,9 +30,9 @@ const mutationState = useMutationState(mutationStateOpts) - - + +
- Data: {JSON.stringify($mutationState.map((state) => state.status))} + Data: {JSON.stringify(mutationState.map((state) => state.status))}
diff --git a/packages/svelte-query/tests/useMutationState/useMutationState.test.ts b/packages/svelte-query/tests/useMutationState/useMutationState.svelte.test.ts similarity index 92% rename from packages/svelte-query/tests/useMutationState/useMutationState.test.ts rename to packages/svelte-query/tests/useMutationState/useMutationState.svelte.test.ts index 9c2910489f..7f82d715f5 100644 --- a/packages/svelte-query/tests/useMutationState/useMutationState.test.ts +++ b/packages/svelte-query/tests/useMutationState/useMutationState.svelte.test.ts @@ -22,14 +22,14 @@ describe('useMutationState', () => { const rendered = render(BaseExample, { props: { - successMutationOpts: { + successMutationOpts: () => ({ mutationKey: ['success'], mutationFn: successMutationFn, - }, - errorMutationOpts: { + }), + errorMutationOpts: () => ({ mutationKey: ['error'], mutationFn: errorMutationFn, - }, + }), }, }) @@ -54,14 +54,14 @@ describe('useMutationState', () => { const rendered = render(BaseExample, { props: { - successMutationOpts: { + successMutationOpts: () => ({ mutationKey: ['success'], mutationFn: successMutationFn, - }, - errorMutationOpts: { + }), + errorMutationOpts: () => ({ mutationKey: ['error'], mutationFn: errorMutationFn, - }, + }), mutationStateOpts: { filters: { status: 'error' }, }, @@ -89,14 +89,14 @@ describe('useMutationState', () => { const rendered = render(BaseExample, { props: { - successMutationOpts: { + successMutationOpts: () => ({ mutationKey: ['success'], mutationFn: successMutationFn, - }, - errorMutationOpts: { + }), + errorMutationOpts: () => ({ mutationKey: ['error'], mutationFn: errorMutationFn, - }, + }), mutationStateOpts: { filters: { mutationKey: ['success'] }, }, diff --git a/packages/svelte-query/tests/utils.svelte.ts b/packages/svelte-query/tests/utils.svelte.ts new file mode 100644 index 0000000000..b573a6edcc --- /dev/null +++ b/packages/svelte-query/tests/utils.svelte.ts @@ -0,0 +1,33 @@ +export function ref(initial: T) { + let value = $state(initial) + + return { + get value() { + return value + }, + set value(newValue) { + value = newValue + }, + } +} + +export function promiseWithResolvers() { + let resolve: (value: T) => void + let reject: (reason?: any) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve: resolve!, reject: reject! } +} + +export function withEffectRoot(fn: () => void | Promise) { + return async () => { + let promise: void | Promise = Promise.resolve() + const cleanup = $effect.root(() => { + promise = fn() + }) + await promise + cleanup() + } +} diff --git a/packages/svelte-query/vite.config.ts b/packages/svelte-query/vite.config.ts index 1249fcc5a8..1ed8e56135 100644 --- a/packages/svelte-query/vite.config.ts +++ b/packages/svelte-query/vite.config.ts @@ -23,8 +23,7 @@ export default defineConfig({ watch: false, environment: 'jsdom', setupFiles: ['./tests/test-setup.ts'], - coverage: { enabled: true, provider: 'istanbul', include: ['src/**/*'] }, + coverage: { enabled: false, provider: 'istanbul', include: ['src/**/*'] }, typecheck: { enabled: true }, - restoreMocks: true, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d9af73f44..a3218f1fba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2680,6 +2680,9 @@ importers: '@testing-library/svelte': specifier: ^5.2.8 version: 5.2.8(svelte@5.39.3)(vite@6.3.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.3)(jiti@2.5.1)(jsdom@27.0.0(postcss@8.5.6))(less@4.3.0)(lightningcss@1.30.1)(msw@2.6.6(@types/node@22.15.3)(typescript@5.8.3))(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)) + '@typescript-eslint/parser': + specifier: ^8.44.1 + version: 8.44.1(eslint@9.36.0(jiti@2.5.1))(typescript@5.8.3) eslint-plugin-svelte: specifier: ^3.11.0 version: 3.11.0(eslint@9.36.0(jiti@2.5.1))(svelte@5.39.3)(ts-node@10.9.2(@types/node@22.15.3)(typescript@5.8.3)) @@ -2708,6 +2711,9 @@ importers: '@tanstack/svelte-query': specifier: workspace:* version: link:../svelte-query + '@typescript-eslint/parser': + specifier: ^8.44.1 + version: 8.44.1(eslint@9.36.0(jiti@2.5.1))(typescript@5.8.3) eslint-plugin-svelte: specifier: ^3.11.0 version: 3.11.0(eslint@9.36.0(jiti@2.5.1))(svelte@5.39.3)(ts-node@10.9.2(@types/node@22.15.3)(typescript@5.8.3)) @@ -2739,6 +2745,9 @@ importers: '@testing-library/svelte': specifier: ^5.2.8 version: 5.2.8(svelte@5.39.3)(vite@6.3.6(@types/node@22.15.3)(jiti@2.5.1)(less@4.3.0)(lightningcss@1.30.1)(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.15.3)(jiti@2.5.1)(jsdom@27.0.0(postcss@8.5.6))(less@4.3.0)(lightningcss@1.30.1)(msw@2.6.6(@types/node@22.15.3)(typescript@5.8.3))(sass@1.88.0)(terser@5.39.1)(tsx@4.20.1)(yaml@2.8.1)) + '@typescript-eslint/parser': + specifier: ^8.44.1 + version: 8.44.1(eslint@9.36.0(jiti@2.5.1))(typescript@5.8.3) eslint-plugin-svelte: specifier: ^3.11.0 version: 3.11.0(eslint@9.36.0(jiti@2.5.1))(svelte@5.39.3)(ts-node@10.9.2(@types/node@22.15.3)(typescript@5.8.3)) @@ -14006,8 +14015,8 @@ packages: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' - svelte-eslint-parser@1.3.0: - resolution: {integrity: sha512-VCgMHKV7UtOGcGLGNFSbmdm6kEKjtzo5nnpGU/mnx4OsFY6bZ7QwRF5DUx+Hokw5Lvdyo8dpk8B1m8mliomrNg==} + svelte-eslint-parser@1.3.3: + resolution: {integrity: sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 @@ -23482,7 +23491,7 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.15.3)(typescript@5.8.3)) postcss-safe-parser: 7.0.1(postcss@8.5.6) semver: 7.7.2 - svelte-eslint-parser: 1.3.0(svelte@5.39.3) + svelte-eslint-parser: 1.3.3(svelte@5.39.3) optionalDependencies: svelte: 5.39.3 transitivePeerDependencies: @@ -29313,7 +29322,7 @@ snapshots: transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.3.0(svelte@5.39.3): + svelte-eslint-parser@1.3.3(svelte@5.39.3): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1