Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 to the nearest `<svelte:boundary>` when `throwOnError` is set
45 changes: 44 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,28 @@ export function createBaseQuery<
createResult(),
)

// A trigger separate from `query` itself: the throw-effect below needs to
// re-run whenever the result updates, but reading `query.isError`/
// `query.isFetching` there would mark them as tracked on the `trackResult`
// proxy (by default) the first time an error occurs β€” permanently widening
// `notifyOnChangeProps` for every consumer of this query from then on, even
// ones that only ever read `data`. Reading the untracked `getCurrentResult()`
// instead avoids this, matching how `useBaseQuery` reads the pre-`trackResult`
// result for its own error check.
//
// This still notifies reliably once `throwOnError` is set, because
// `QueryObserver` force-adds `'error'` to the notified props whenever
// `options.throwOnError` is set (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 +119,32 @@ export function createBaseQuery<
//
// this could technically be its own effect but that doesn't seem necessary
update(createResult())
resultVersion++
},
)

$effect(() => {
// Depend on `resultVersion`, NOT on `query` itself, so this reaction re-runs
// whenever the result updates without marking `isError`/`isFetching`/`error`
// as tracked props on `query` (see `resultVersion` above). Reads the actual
// values from `observer.getCurrentResult()`, which is untracked.
void resultVersion
const currentResult = observer.getCurrentResult()

// Must throw from inside this reaction (not from the `subscribe` callback
// above, which runs through notifyManager's batching outside any active
// Svelte reaction) β€” otherwise `<svelte:boundary>` never sees the error.
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 type { QueryObserverResult } from '@tanstack/query-core'

describe('createInfiniteQuery', () => {
Expand Down Expand Up @@ -152,4 +154,32 @@ 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()
})
})
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