Skip to content
Draft
5 changes: 5 additions & 0 deletions .changeset/tame-parrots-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/svelte-query': minor
---

feat(svelte-query): propagate errors from `createQuery`/`createInfiniteQuery` to the nearest `<svelte:boundary>` when `throwOnError` is set
2 changes: 1 addition & 1 deletion docs/framework/svelte/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,6 @@ createQuery(() => ({
```

- Errors can be caught and reset using Svelte's native `<svelte:boundary>` component.
Set `throwOnError` option to `true` to make sure errors are thrown to the `<svelte:boundary>` component.
Set `throwOnError` option to `true` on `createQuery`/`createInfiniteQuery` to make sure errors are thrown to the `<svelte:boundary>` component.

- Since property tracking is handled through Svelte's fine-grained reactivity, options like `notifyOnChangeProps` are not needed
35 changes: 34 additions & 1 deletion packages/svelte-query/src/createBaseQuery.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { shouldThrowError } from '@tanstack/query-core'
import { useIsRestoring } from './useIsRestoring.js'
import { useQueryClient } from './useQueryClient.js'
import { createRawRef } from './containers.svelte.js'
Expand Down Expand Up @@ -71,10 +72,22 @@ export function createBaseQuery<
createResult(),
)

// Separate trigger so the throw-effect below can react to result updates
// without reading `query.isError`/`isFetching` there, which would mark them
// as tracked on the `trackResult` proxy and permanently widen
// `notifyOnChangeProps` for every consumer of this query. This still
// notifies reliably once `throwOnError` is set, because `QueryObserver`
// force-adds `'error'` to the notified props in that case (see
// `queryObserver.ts`), regardless of what any consumer has read.
let resultVersion = $state(0)

$effect(() => {
const unsubscribe = isRestoring.current
? () => undefined
: observer.subscribe(() => update(createResult()))
: observer.subscribe(() => {
update(createResult())
resultVersion++
})
observer.updateResult()
return unsubscribe
})
Expand All @@ -100,8 +113,28 @@ export function createBaseQuery<
//
// this could technically be its own effect but that doesn't seem necessary
update(createResult())
resultVersion++
},
)

$effect(() => {
// Must throw from inside this reaction, not from the `subscribe` callback
// above (which runs outside any active Svelte reaction) β€” otherwise
// `<svelte:boundary>` never sees the error.
void resultVersion
const currentResult = observer.getCurrentResult()

if (
currentResult.isError &&
!currentResult.isFetching &&
shouldThrowError(resolvedOptions.throwOnError, [
currentResult.error,
observer.getCurrentQuery(),
])
) {
throw currentResult.error
}
})

return query
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import type { QueryClient } from '@tanstack/query-core'
import { setQueryClientContext } from '../../src/index.js'
import type { Accessor, CreateInfiniteQueryOptions } from '../../src/types.js'
import ErrorBoundaryContent from './ErrorBoundaryContent.svelte'

type Props = {
queryClient: QueryClient
options: Accessor<CreateInfiniteQueryOptions>
}

let { queryClient, options }: Props = $props()

setQueryClientContext(queryClient)

onMount(() => queryClient.mount())
onDestroy(() => queryClient.unmount())
</script>

<svelte:boundary onerror={(_err, _reset) => {}}>
<ErrorBoundaryContent {options} />
{#snippet failed(error, _reset)}
<div data-testid="error-boundary">
{error instanceof Error ? error.message : String(error)}
</div>
{/snippet}
</svelte:boundary>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script lang="ts">
import { createInfiniteQuery } from '../../src/index.js'
import type { Accessor, CreateInfiniteQueryOptions } from '../../src/types.js'

type Props = {
options: Accessor<CreateInfiniteQueryOptions>
}

let { options }: Props = $props()

const query = createInfiniteQuery(options)
</script>

<div data-testid="status">{query.status}</div>
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render } from '@testing-library/svelte'
import { QueryClient } from '@tanstack/query-core'
import { queryKey } from '@tanstack/query-test-utils'
import { ref } from '../utils.svelte.js'
import Base from './Base.svelte'
import Select from './Select.svelte'
import ChangeClient from './ChangeClient.svelte'
import ErrorBoundary from './ErrorBoundary.svelte'
import InitialData from './InitialData.svelte'
import type { QueryObserverResult } from '@tanstack/query-core'

Expand Down Expand Up @@ -171,4 +173,108 @@ describe('createInfiniteQuery', () => {
rendered.getByText('Data: {"pages":[7,8],"pageParams":[7,8]}'),
).toBeInTheDocument()
})

it('should throw error to the nearest svelte:boundary when throwOnError is true', async () => {
const key = queryKey()
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)

const rendered = render(ErrorBoundary, {
props: {
queryClient,
options: () => ({
queryKey: key,
queryFn: () => Promise.reject(new Error('Error test')),
getNextPageParam: () => undefined,
initialPageParam: 0,
retry: false,
throwOnError: true,
}),
},
})

await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByTestId('error-boundary')).toHaveTextContent(
'Error test',
)

consoleMock.mockRestore()
})

it('should throw error to the nearest svelte:boundary when throwOnError function returns true', async () => {
const key = queryKey()
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)

const rendered = render(ErrorBoundary, {
props: {
queryClient,
options: () => ({
queryKey: key,
queryFn: () => Promise.reject(new Error('Local Error')),
getNextPageParam: () => undefined,
initialPageParam: 0,
retry: false,
throwOnError: (err: Error) => err.message === 'Local Error',
}),
},
})

await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByTestId('error-boundary')).toHaveTextContent(
'Local Error',
)

consoleMock.mockRestore()
})

it('should not throw to the nearest svelte:boundary when throwOnError function returns false', async () => {
const key = queryKey()

const rendered = render(ErrorBoundary, {
props: {
queryClient,
options: () => ({
queryKey: key,
queryFn: () => Promise.reject(new Error('Local Error')),
getNextPageParam: () => undefined,
initialPageParam: 0,
retry: false,
throwOnError: (err: Error) => err.message !== 'Local Error',
}),
},
})

await vi.advanceTimersByTimeAsync(0)
expect(rendered.queryByTestId('error-boundary')).not.toBeInTheDocument()
expect(rendered.getByTestId('status')).toHaveTextContent('error')
})

it('should throw error to the nearest svelte:boundary when queryFn rejects with a falsy error and throwOnError is in use', async () => {
const key = queryKey()
const consoleMock = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)

const rendered = render(ErrorBoundary, {
props: {
queryClient,
options: () => ({
queryKey: key,
queryFn: () => Promise.reject(),
getNextPageParam: () => undefined,
initialPageParam: 0,
retry: false,
throwOnError: true,
}),
},
})

await vi.advanceTimersByTimeAsync(0)
expect(rendered.getByTestId('error-boundary')).toBeInTheDocument()

consoleMock.mockRestore()
})
})
28 changes: 28 additions & 0 deletions packages/svelte-query/tests/createQuery/ErrorBoundary.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import type { QueryClient } from '@tanstack/query-core'
import { setQueryClientContext } from '../../src/index.js'
import type { Accessor, CreateQueryOptions } from '../../src/index.js'
import ErrorBoundaryContent from './ErrorBoundaryContent.svelte'

type Props = {
queryClient: QueryClient
options: Accessor<CreateQueryOptions>
}

let { queryClient, options }: Props = $props()

setQueryClientContext(queryClient)

onMount(() => queryClient.mount())
onDestroy(() => queryClient.unmount())
</script>

<svelte:boundary onerror={(_err, _reset) => {}}>
<ErrorBoundaryContent {options} />
{#snippet failed(error, _reset)}
<div data-testid="error-boundary">
{error instanceof Error ? error.message : String(error)}
</div>
{/snippet}
</svelte:boundary>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import type { QueryClient } from '@tanstack/query-core'
import { setQueryClientContext } from '../../src/index.js'
import type { Accessor, CreateQueryOptions } from '../../src/index.js'
import ErrorBoundaryChangeClientContent from './ErrorBoundaryChangeClientContent.svelte'

type Props = {
queryClient: QueryClient
currentClient: Accessor<QueryClient>
options: Accessor<CreateQueryOptions>
}

let { queryClient, currentClient, options }: Props = $props()

setQueryClientContext(queryClient)

onMount(() => queryClient.mount())
onDestroy(() => queryClient.unmount())
</script>

<svelte:boundary onerror={(_err, _reset) => {}}>
<ErrorBoundaryChangeClientContent {currentClient} {options} />
{#snippet failed(error, _reset)}
<div data-testid="error-boundary">
{error instanceof Error ? error.message : String(error)}
</div>
{/snippet}
</svelte:boundary>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts">
import type { QueryClient } from '@tanstack/query-core'
import { createQuery } from '../../src/index.js'
import type { Accessor, CreateQueryOptions } from '../../src/index.js'

type Props = {
currentClient: Accessor<QueryClient>
options: Accessor<CreateQueryOptions>
}

let { currentClient, options }: Props = $props()

const query = createQuery(options, currentClient)
</script>

<div data-testid="status">{query.status}</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script lang="ts">
import { createQuery } from '../../src/index.js'
import type { Accessor, CreateQueryOptions } from '../../src/index.js'

type Props = {
options: Accessor<CreateQueryOptions>
}

let { options }: Props = $props()

const query = createQuery(options)
</script>

<div data-testid="status">{query.status}</div>
Loading
Loading