From 07eca1996738a3d8912d18bc84e4e913fb816086 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Fri, 28 Aug 2026 14:23:40 +0900 Subject: [PATCH] feat(svelte-query): propagate errors to the nearest svelte:boundary when throwOnError is set --- .changeset/tame-parrots-shave.md | 5 + .../src/createBaseQuery.svelte.ts | 45 +++- .../createInfiniteQuery/ErrorBoundary.svelte | 28 +++ .../ErrorBoundaryContent.svelte | 14 ++ .../createInfiniteQuery.svelte.test.ts | 30 +++ .../tests/createQuery/ErrorBoundary.svelte | 28 +++ .../ErrorBoundaryChangeClient.svelte | 29 +++ .../ErrorBoundaryChangeClientContent.svelte | 16 ++ .../createQuery/ErrorBoundaryContent.svelte | 14 ++ .../createQuery/createQuery.svelte.test.ts | 235 ++++++++++++++++++ 10 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 .changeset/tame-parrots-shave.md create mode 100644 packages/svelte-query/tests/createInfiniteQuery/ErrorBoundary.svelte create mode 100644 packages/svelte-query/tests/createInfiniteQuery/ErrorBoundaryContent.svelte create mode 100644 packages/svelte-query/tests/createQuery/ErrorBoundary.svelte create mode 100644 packages/svelte-query/tests/createQuery/ErrorBoundaryChangeClient.svelte create mode 100644 packages/svelte-query/tests/createQuery/ErrorBoundaryChangeClientContent.svelte create mode 100644 packages/svelte-query/tests/createQuery/ErrorBoundaryContent.svelte diff --git a/.changeset/tame-parrots-shave.md b/.changeset/tame-parrots-shave.md new file mode 100644 index 00000000000..24c7ef0273f --- /dev/null +++ b/.changeset/tame-parrots-shave.md @@ -0,0 +1,5 @@ +--- +'@tanstack/svelte-query': minor +--- + +feat(svelte-query): propagate errors to the nearest `` when `throwOnError` is set diff --git a/packages/svelte-query/src/createBaseQuery.svelte.ts b/packages/svelte-query/src/createBaseQuery.svelte.ts index 03fc6b28db4..ade3acd951c 100644 --- a/packages/svelte-query/src/createBaseQuery.svelte.ts +++ b/packages/svelte-query/src/createBaseQuery.svelte.ts @@ -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' @@ -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 }) @@ -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 `` never sees the error. + if ( + currentResult.isError && + !currentResult.isFetching && + shouldThrowError(resolvedOptions.throwOnError, [ + currentResult.error, + observer.getCurrentQuery(), + ]) + ) { + throw currentResult.error + } + }) + return query } diff --git a/packages/svelte-query/tests/createInfiniteQuery/ErrorBoundary.svelte b/packages/svelte-query/tests/createInfiniteQuery/ErrorBoundary.svelte new file mode 100644 index 00000000000..aa708d087a2 --- /dev/null +++ b/packages/svelte-query/tests/createInfiniteQuery/ErrorBoundary.svelte @@ -0,0 +1,28 @@ + + + {}}> + + {#snippet failed(error, _reset)} +
+ {error instanceof Error ? error.message : String(error)} +
+ {/snippet} +
diff --git a/packages/svelte-query/tests/createInfiniteQuery/ErrorBoundaryContent.svelte b/packages/svelte-query/tests/createInfiniteQuery/ErrorBoundaryContent.svelte new file mode 100644 index 00000000000..aabb11b558e --- /dev/null +++ b/packages/svelte-query/tests/createInfiniteQuery/ErrorBoundaryContent.svelte @@ -0,0 +1,14 @@ + + +
{query.status}
diff --git a/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.svelte.test.ts b/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.svelte.test.ts index 04ec348588e..275e8bc38cf 100644 --- a/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.svelte.test.ts +++ b/packages/svelte-query/tests/createInfiniteQuery/createInfiniteQuery.svelte.test.ts @@ -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', () => { @@ -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() + }) }) diff --git a/packages/svelte-query/tests/createQuery/ErrorBoundary.svelte b/packages/svelte-query/tests/createQuery/ErrorBoundary.svelte new file mode 100644 index 00000000000..ddc610d5d2a --- /dev/null +++ b/packages/svelte-query/tests/createQuery/ErrorBoundary.svelte @@ -0,0 +1,28 @@ + + + {}}> + + {#snippet failed(error, _reset)} +
+ {error instanceof Error ? error.message : String(error)} +
+ {/snippet} +
diff --git a/packages/svelte-query/tests/createQuery/ErrorBoundaryChangeClient.svelte b/packages/svelte-query/tests/createQuery/ErrorBoundaryChangeClient.svelte new file mode 100644 index 00000000000..18f53abbdab --- /dev/null +++ b/packages/svelte-query/tests/createQuery/ErrorBoundaryChangeClient.svelte @@ -0,0 +1,29 @@ + + + {}}> + + {#snippet failed(error, _reset)} +
+ {error instanceof Error ? error.message : String(error)} +
+ {/snippet} +
diff --git a/packages/svelte-query/tests/createQuery/ErrorBoundaryChangeClientContent.svelte b/packages/svelte-query/tests/createQuery/ErrorBoundaryChangeClientContent.svelte new file mode 100644 index 00000000000..eb1f3314795 --- /dev/null +++ b/packages/svelte-query/tests/createQuery/ErrorBoundaryChangeClientContent.svelte @@ -0,0 +1,16 @@ + + +
{query.status}
diff --git a/packages/svelte-query/tests/createQuery/ErrorBoundaryContent.svelte b/packages/svelte-query/tests/createQuery/ErrorBoundaryContent.svelte new file mode 100644 index 00000000000..83096385992 --- /dev/null +++ b/packages/svelte-query/tests/createQuery/ErrorBoundaryContent.svelte @@ -0,0 +1,14 @@ + + +
{query.status}
diff --git a/packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts b/packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts index e8f7ac82fb0..4160e77166a 100644 --- a/packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts +++ b/packages/svelte-query/tests/createQuery/createQuery.svelte.test.ts @@ -13,6 +13,8 @@ import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, createQuery, keepPreviousData } from '../../src/index.js' import { promiseWithResolvers, withEffectRoot } from '../utils.svelte.js' import Base from './Base.svelte' +import ErrorBoundary from './ErrorBoundary.svelte' +import ErrorBoundaryChangeClient from './ErrorBoundaryChangeClient.svelte' import Counter from './Counter.svelte' import IsRestoring from './IsRestoring.svelte' import Select from './Select.svelte' @@ -1234,6 +1236,41 @@ describe('createQuery', () => { }), ) + it( + 'should not widen tracked props for unrelated data-only consumers after an error occurs', + withEffectRoot(async () => { + const key = queryKey() + const dataOnlyRuns: Array = [] + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.reject(new Error('fail')), + retry: false, + // `false` never satisfies `shouldThrowError`, so the query settles + // into an error state without throwing — this is the case where + // the throw-effect's `!query.isFetching` check (guarded behind + // `query.isError`) reads `isFetching` and, unless read from the + // untracked result, would mark it tracked from then on. + throwOnError: false, + }), + () => queryClient, + ) + + // This effect only ever reads `data`. Once the query above has settled + // into an error state, `isFetching` transitions on a later refetch must + // not cause this unrelated, data-only effect to re-run. + $effect(() => { + dataOnlyRuns.push(query.data) + }) + + await vi.advanceTimersByTimeAsync(0) + await query.refetch() + + expect(dataOnlyRuns).toHaveLength(1) + }), + ) + it( 'should always re-render if we are tracking props but not using any', withEffectRoot(async () => { @@ -1580,6 +1617,149 @@ describe('createQuery', () => { expect(rendered.getByTestId('error')).toHaveTextContent('Local Error') }) + 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')), + 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')), + 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 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(), + retry: false, + throwOnError: true, + }), + }, + }) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByTestId('error-boundary')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + + it('should throw a cached error to the nearest svelte:boundary without refetching', async () => { + const key = queryKey() + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + // Pre-populate the cache with an error result via a first, unmounted subscriber. + const first = render(Base, { + props: { + queryClient, + options: () => ({ + queryKey: key, + queryFn: () => Promise.reject(new Error('Pre-existing error')), + retry: false, + throwOnError: false, + }), + }, + }) + await vi.advanceTimersByTimeAsync(0) + first.unmount() + + // Now mount a NEW component subscribing to the same key with throwOnError: true. + // `enabled: false` guarantees no new fetch happens on mount, so the only way + // this passes is if the throw-effect fires off the PRE-EXISTING cached error. + const queryFn = vi.fn(() => Promise.reject(new Error('should not fetch'))) + const rendered = render(ErrorBoundary, { + props: { + queryClient, + options: () => ({ + queryKey: key, + queryFn, + retry: false, + enabled: false, + throwOnError: true, + }), + }, + }) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByTestId('error-boundary')).toHaveTextContent( + 'Pre-existing error', + ) + expect(queryFn).not.toHaveBeenCalled() + + consoleMock.mockRestore() + }) + + it( + 'should update with data if we observe no properties and throwOnError', + withEffectRoot(async () => { + const key = queryKey() + + const query = createQuery( + () => ({ + queryKey: key, + queryFn: () => Promise.resolve('data'), + throwOnError: true, + }), + () => queryClient, + ) + + await vi.advanceTimersByTimeAsync(0) + expect(queryClient.isFetching()).toBe(0) + expect(query.data).toBe('data') + }), + ) + it( 'should support changing provided query client', withEffectRoot(() => { @@ -1611,6 +1791,61 @@ describe('createQuery', () => { }), ) + it('should throw a cached error to the nearest svelte:boundary when the query client changes', async () => { + const key = queryKey() + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + const queryClient1 = new QueryClient() + const queryClient2 = new QueryClient() + + // Pre-populate queryClient2's cache with an error result via a first, + // unmounted subscriber, so switching to it never needs to fetch. + const first = render(Base, { + props: { + queryClient: queryClient2, + options: () => ({ + queryKey: key, + queryFn: () => Promise.reject(new Error('Pre-existing error')), + retry: false, + throwOnError: false, + }), + }, + }) + await vi.advanceTimersByTimeAsync(0) + first.unmount() + + let currentClient = $state(queryClient1) + const queryFn = vi.fn(() => Promise.reject(new Error('should not fetch'))) + + const rendered = render(ErrorBoundaryChangeClient, { + props: { + queryClient: queryClient1, + currentClient: () => currentClient, + options: () => ({ + queryKey: key, + queryFn, + retry: false, + enabled: false, + throwOnError: true, + }), + }, + }) + await vi.advanceTimersByTimeAsync(0) + expect(rendered.queryByTestId('error-boundary')).not.toBeInTheDocument() + + currentClient = queryClient2 + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByTestId('error-boundary')).toHaveTextContent( + 'Pre-existing error', + ) + expect(queryFn).not.toHaveBeenCalled() + + consoleMock.mockRestore() + }) + it('should not fetch for the duration of the restoring period when isRestoring is true', async () => { const queryFn = vi.fn(() => sleep(10).then(() => 'data'))