Skip to content

Commit a6d9b58

Browse files
committed
feat(svelte-query): propagate errors to the nearest svelte:boundary when throwOnError is set
1 parent 2969edf commit a6d9b58

8 files changed

Lines changed: 295 additions & 1 deletion

File tree

.changeset/tame-parrots-shave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/svelte-query': minor
3+
---
4+
5+
feat(svelte-query): propagate errors to the nearest `<svelte:boundary>` when `throwOnError` is set

packages/svelte-query/src/createBaseQuery.svelte.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { shouldThrowError } from '@tanstack/query-core'
12
import { useIsRestoring } from './useIsRestoring.js'
23
import { useQueryClient } from './useQueryClient.js'
34
import { createRawRef } from './containers.svelte.js'
@@ -71,10 +72,28 @@ export function createBaseQuery<
7172
createResult(),
7273
)
7374

75+
// A trigger separate from `query` itself: the throw-effect below needs to
76+
// re-run whenever the result updates, but reading `query.isError`/
77+
// `query.isFetching` there would mark them as tracked on the `trackResult`
78+
// proxy (by default) the first time an error occurs — permanently widening
79+
// `notifyOnChangeProps` for every consumer of this query from then on, even
80+
// ones that only ever read `data`. Reading the untracked `getCurrentResult()`
81+
// instead avoids this, matching how `useBaseQuery` reads the pre-`trackResult`
82+
// result for its own error check.
83+
//
84+
// This still notifies reliably once `throwOnError` is set, because
85+
// `QueryObserver` force-adds `'error'` to the notified props whenever
86+
// `options.throwOnError` is set (see `queryObserver.ts`), regardless of what
87+
// any consumer has read.
88+
let resultVersion = $state(0)
89+
7490
$effect(() => {
7591
const unsubscribe = isRestoring.current
7692
? () => undefined
77-
: observer.subscribe(() => update(createResult()))
93+
: observer.subscribe(() => {
94+
update(createResult())
95+
resultVersion++
96+
})
7897
observer.updateResult()
7998
return unsubscribe
8099
})
@@ -100,8 +119,32 @@ export function createBaseQuery<
100119
//
101120
// this could technically be its own effect but that doesn't seem necessary
102121
update(createResult())
122+
resultVersion++
103123
},
104124
)
105125

126+
$effect(() => {
127+
// Depend on `resultVersion`, NOT on `query` itself, so this reaction re-runs
128+
// whenever the result updates without marking `isError`/`isFetching`/`error`
129+
// as tracked props on `query` (see `resultVersion` above). Reads the actual
130+
// values from `observer.getCurrentResult()`, which is untracked.
131+
void resultVersion
132+
const currentResult = observer.getCurrentResult()
133+
134+
// Must throw from inside this reaction (not from the `subscribe` callback
135+
// above, which runs through notifyManager's batching outside any active
136+
// Svelte reaction) — otherwise `<svelte:boundary>` never sees the error.
137+
if (
138+
currentResult.isError &&
139+
!currentResult.isFetching &&
140+
shouldThrowError(resolvedOptions.throwOnError, [
141+
currentResult.error,
142+
observer.getCurrentQuery(),
143+
])
144+
) {
145+
throw currentResult.error
146+
}
147+
})
148+
106149
return query
107150
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<script lang="ts">
2+
import { onDestroy, onMount } from 'svelte'
3+
import type { QueryClient } from '@tanstack/query-core'
4+
import { setQueryClientContext } from '../../src/index.js'
5+
import type { Accessor, CreateInfiniteQueryOptions } from '../../src/types.js'
6+
import ErrorBoundaryContent from './ErrorBoundaryContent.svelte'
7+
8+
type Props = {
9+
queryClient: QueryClient
10+
options: Accessor<CreateInfiniteQueryOptions>
11+
}
12+
13+
let { queryClient, options }: Props = $props()
14+
15+
setQueryClientContext(queryClient)
16+
17+
onMount(() => queryClient.mount())
18+
onDestroy(() => queryClient.unmount())
19+
</script>
20+
21+
<svelte:boundary onerror={(_err, _reset) => {}}>
22+
<ErrorBoundaryContent {options} />
23+
{#snippet failed(error, _reset)}
24+
<div data-testid="error-boundary">
25+
{error instanceof Error ? error.message : String(error)}
26+
</div>
27+
{/snippet}
28+
</svelte:boundary>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script lang="ts">
2+
import { createInfiniteQuery } from '../../src/index.js'
3+
import type { Accessor, CreateInfiniteQueryOptions } from '../../src/types.js'
4+
5+
type Props = {
6+
options: Accessor<CreateInfiniteQueryOptions>
7+
}
8+
9+
let { options }: Props = $props()
10+
11+
const query = createInfiniteQuery(options)
12+
</script>
13+
14+
<div data-testid="status">{query.status}</div>

packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.svelte.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22
import { fireEvent, render } from '@testing-library/svelte'
33
import { QueryClient } from '@tanstack/query-core'
4+
import { queryKey } from '@tanstack/query-test-utils'
45
import { ref } from '../utils.svelte.js'
56
import Base from './Base.svelte'
67
import Select from './Select.svelte'
78
import ChangeClient from './ChangeClient.svelte'
9+
import ErrorBoundary from './ErrorBoundary.svelte'
810
import type { QueryObserverResult } from '@tanstack/query-core'
911

1012
describe('createInfiniteQuery', () => {
@@ -152,4 +154,32 @@ describe('createInfiniteQuery', () => {
152154
rendered.getByText('Data: {"pages":[7,8],"pageParams":[7,8]}'),
153155
).toBeInTheDocument()
154156
})
157+
158+
it('should throw error to the nearest svelte:boundary when throwOnError is true', async () => {
159+
const key = queryKey()
160+
const consoleMock = vi
161+
.spyOn(console, 'error')
162+
.mockImplementation(() => undefined)
163+
164+
const rendered = render(ErrorBoundary, {
165+
props: {
166+
queryClient,
167+
options: () => ({
168+
queryKey: key,
169+
queryFn: () => Promise.reject(new Error('Error test')),
170+
getNextPageParam: () => undefined,
171+
initialPageParam: 0,
172+
retry: false,
173+
throwOnError: true,
174+
}),
175+
},
176+
})
177+
178+
await vi.advanceTimersByTimeAsync(0)
179+
expect(rendered.getByTestId('error-boundary')).toHaveTextContent(
180+
'Error test',
181+
)
182+
183+
consoleMock.mockRestore()
184+
})
155185
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<script lang="ts">
2+
import { onDestroy, onMount } from 'svelte'
3+
import type { QueryClient } from '@tanstack/query-core'
4+
import { setQueryClientContext } from '../../src/index.js'
5+
import type { Accessor, CreateQueryOptions } from '../../src/index.js'
6+
import ErrorBoundaryContent from './ErrorBoundaryContent.svelte'
7+
8+
type Props = {
9+
queryClient: QueryClient
10+
options: Accessor<CreateQueryOptions>
11+
}
12+
13+
let { queryClient, options }: Props = $props()
14+
15+
setQueryClientContext(queryClient)
16+
17+
onMount(() => queryClient.mount())
18+
onDestroy(() => queryClient.unmount())
19+
</script>
20+
21+
<svelte:boundary onerror={(_err, _reset) => {}}>
22+
<ErrorBoundaryContent {options} />
23+
{#snippet failed(error, _reset)}
24+
<div data-testid="error-boundary">
25+
{error instanceof Error ? error.message : String(error)}
26+
</div>
27+
{/snippet}
28+
</svelte:boundary>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script lang="ts">
2+
import { createQuery } from '../../src/index.js'
3+
import type { Accessor, CreateQueryOptions } from '../../src/index.js'
4+
5+
type Props = {
6+
options: Accessor<CreateQueryOptions>
7+
}
8+
9+
let { options }: Props = $props()
10+
11+
const query = createQuery(options)
12+
</script>
13+
14+
<div data-testid="status">{query.status}</div>

packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { queryKey, sleep } from '@tanstack/query-test-utils'
1313
import { QueryClient, createQuery, keepPreviousData } from '../../src/index.js'
1414
import { promiseWithResolvers, withEffectRoot } from '../utils.svelte.js'
1515
import Base from './Base.svelte'
16+
import ErrorBoundary from './ErrorBoundary.svelte'
1617
import Counter from './Counter.svelte'
1718
import IsRestoring from './IsRestoring.svelte'
1819
import Select from './Select.svelte'
@@ -1234,6 +1235,41 @@ describe('createQuery', () => {
12341235
}),
12351236
)
12361237

1238+
it(
1239+
'should not widen tracked props for unrelated data-only consumers after an error occurs',
1240+
withEffectRoot(async () => {
1241+
const key = queryKey()
1242+
const dataOnlyRuns: Array<string | undefined> = []
1243+
1244+
const query = createQuery<string>(
1245+
() => ({
1246+
queryKey: key,
1247+
queryFn: () => Promise.reject(new Error('fail')),
1248+
retry: false,
1249+
// `false` never satisfies `shouldThrowError`, so the query settles
1250+
// into an error state without throwing — this is the case where
1251+
// the throw-effect's `!query.isFetching` check (guarded behind
1252+
// `query.isError`) reads `isFetching` and, unless read from the
1253+
// untracked result, would mark it tracked from then on.
1254+
throwOnError: false,
1255+
}),
1256+
() => queryClient,
1257+
)
1258+
1259+
// This effect only ever reads `data`. Once the query above has settled
1260+
// into an error state, `isFetching` transitions on a later refetch must
1261+
// not cause this unrelated, data-only effect to re-run.
1262+
$effect(() => {
1263+
dataOnlyRuns.push(query.data)
1264+
})
1265+
1266+
await vi.advanceTimersByTimeAsync(0)
1267+
await query.refetch()
1268+
1269+
expect(dataOnlyRuns).toHaveLength(1)
1270+
}),
1271+
)
1272+
12371273
it(
12381274
'should always re-render if we are tracking props but not using any',
12391275
withEffectRoot(async () => {
@@ -1580,6 +1616,102 @@ describe('createQuery', () => {
15801616
expect(rendered.getByTestId('error')).toHaveTextContent('Local Error')
15811617
})
15821618

1619+
it('should throw error to the nearest svelte:boundary when throwOnError is true', async () => {
1620+
const key = queryKey()
1621+
const consoleMock = vi
1622+
.spyOn(console, 'error')
1623+
.mockImplementation(() => undefined)
1624+
1625+
const rendered = render(ErrorBoundary, {
1626+
props: {
1627+
queryClient,
1628+
options: () => ({
1629+
queryKey: key,
1630+
queryFn: () => Promise.reject(new Error('Error test')),
1631+
retry: false,
1632+
throwOnError: true,
1633+
}),
1634+
},
1635+
})
1636+
1637+
await vi.advanceTimersByTimeAsync(0)
1638+
expect(rendered.getByTestId('error-boundary')).toHaveTextContent(
1639+
'Error test',
1640+
)
1641+
1642+
consoleMock.mockRestore()
1643+
})
1644+
1645+
it('should throw error to the nearest svelte:boundary when throwOnError function returns true', async () => {
1646+
const key = queryKey()
1647+
const consoleMock = vi
1648+
.spyOn(console, 'error')
1649+
.mockImplementation(() => undefined)
1650+
1651+
const rendered = render(ErrorBoundary, {
1652+
props: {
1653+
queryClient,
1654+
options: () => ({
1655+
queryKey: key,
1656+
queryFn: () => Promise.reject(new Error('Local Error')),
1657+
retry: false,
1658+
throwOnError: (err: Error) => err.message === 'Local Error',
1659+
}),
1660+
},
1661+
})
1662+
1663+
await vi.advanceTimersByTimeAsync(0)
1664+
expect(rendered.getByTestId('error-boundary')).toHaveTextContent(
1665+
'Local Error',
1666+
)
1667+
1668+
consoleMock.mockRestore()
1669+
})
1670+
1671+
it('should throw error to the nearest svelte:boundary when queryFn rejects with a falsy error and throwOnError is in use', async () => {
1672+
const key = queryKey()
1673+
const consoleMock = vi
1674+
.spyOn(console, 'error')
1675+
.mockImplementation(() => undefined)
1676+
1677+
const rendered = render(ErrorBoundary, {
1678+
props: {
1679+
queryClient,
1680+
options: () => ({
1681+
queryKey: key,
1682+
queryFn: () => Promise.reject(),
1683+
retry: false,
1684+
throwOnError: true,
1685+
}),
1686+
},
1687+
})
1688+
1689+
await vi.advanceTimersByTimeAsync(0)
1690+
expect(rendered.getByTestId('error-boundary')).toBeInTheDocument()
1691+
1692+
consoleMock.mockRestore()
1693+
})
1694+
1695+
it(
1696+
'should update with data if we observe no properties and throwOnError',
1697+
withEffectRoot(async () => {
1698+
const key = queryKey()
1699+
1700+
const query = createQuery<string>(
1701+
() => ({
1702+
queryKey: key,
1703+
queryFn: () => Promise.resolve('data'),
1704+
throwOnError: true,
1705+
}),
1706+
() => queryClient,
1707+
)
1708+
1709+
await vi.advanceTimersByTimeAsync(0)
1710+
expect(queryClient.isFetching()).toBe(0)
1711+
expect(query.data).toBe('data')
1712+
}),
1713+
)
1714+
15831715
it(
15841716
'should support changing provided query client',
15851717
withEffectRoot(() => {

0 commit comments

Comments
 (0)