Skip to content

Commit 968d6a8

Browse files
hmndlachlancollins
andauthored
fix(svelte-query): state_unsafe_mutation error with useIs... (#9493)
* fix(svelte-query): don't wrap observers in derived to avoid state_unsafe_mutation fixes useIsFetching and useIsMutating in svelte 5 adapter * test(svelte-query): wrap (useIs...) tests in QueryClientProvider to test non colocated query * fix(svelte-query): update observers when passed in query client changes * fix(svelte-query): simplify creatMutation sub/unsub * Refactor result handling in createMutation.svelte.ts Replace derived state with direct state and add watchChanges for result updates. --------- Co-authored-by: Lachlan Collins <[email protected]>
1 parent eea078e commit 968d6a8

12 files changed

+236
-84
lines changed

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

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { untrack } from 'svelte'
21
import { useIsRestoring } from './useIsRestoring.js'
32
import { useQueryClient } from './useQueryClient.js'
43
import { createRawRef } from './containers.svelte.js'
4+
import { watchChanges } from './utils.svelte.js'
55
import type { QueryClient, QueryKey, QueryObserver } from '@tanstack/query-core'
66
import type {
77
Accessor,
@@ -39,12 +39,26 @@ export function createBaseQuery<
3939
})
4040

4141
/** Creates the observer */
42-
const observer = $derived(
42+
// svelte-ignore state_referenced_locally - intentional, initial value
43+
let observer = $state(
4344
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
4445
client,
45-
untrack(() => resolvedOptions),
46+
resolvedOptions,
4647
),
4748
)
49+
watchChanges(
50+
() => client,
51+
'pre',
52+
() => {
53+
observer = new Observer<
54+
TQueryFnData,
55+
TError,
56+
TData,
57+
TQueryData,
58+
TQueryKey
59+
>(client, resolvedOptions)
60+
},
61+
)
4862

4963
function createResult() {
5064
const result = observer.getOptimisticResult(resolvedOptions)
@@ -65,19 +79,29 @@ export function createBaseQuery<
6579
return unsubscribe
6680
})
6781

68-
$effect.pre(() => {
69-
observer.setOptions(resolvedOptions)
70-
// The only reason this is necessary is because of `isRestoring`.
71-
// Because we don't subscribe while restoring, the following can occur:
72-
// - `isRestoring` is true
73-
// - `isRestoring` becomes false
74-
// - `observer.subscribe` and `observer.updateResult` is called in the above effect,
75-
// but the subsequent `fetch` has already completed
76-
// - `result` misses the intermediate restored-but-not-fetched state
77-
//
78-
// this could technically be its own effect but that doesn't seem necessary
79-
update(createResult())
80-
})
82+
watchChanges(
83+
() => resolvedOptions,
84+
'pre',
85+
() => {
86+
observer.setOptions(resolvedOptions)
87+
},
88+
)
89+
watchChanges(
90+
() => [resolvedOptions, observer],
91+
'pre',
92+
() => {
93+
// The only reason this is necessary is because of `isRestoring`.
94+
// Because we don't subscribe while restoring, the following can occur:
95+
// - `isRestoring` is true
96+
// - `isRestoring` becomes false
97+
// - `observer.subscribe` and `observer.updateResult` is called in the above effect,
98+
// but the subsequent `fetch` has already completed
99+
// - `result` misses the intermediate restored-but-not-fetched state
100+
//
101+
// this could technically be its own effect but that doesn't seem necessary
102+
update(createResult())
103+
},
104+
)
81105

82106
return query
83107
}
Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { onDestroy } from 'svelte'
2-
31
import { MutationObserver, noop, notifyManager } from '@tanstack/query-core'
42
import { useQueryClient } from './useQueryClient.js'
3+
import { watchChanges } from './utils.svelte.js'
54
import type {
65
Accessor,
76
CreateMutateFunction,
@@ -24,48 +23,69 @@ export function createMutation<
2423
options: Accessor<CreateMutationOptions<TData, TError, TVariables, TContext>>,
2524
queryClient?: Accessor<QueryClient>,
2625
): CreateMutationResult<TData, TError, TVariables, TContext> {
27-
const client = useQueryClient(queryClient?.())
26+
const client = $derived(useQueryClient(queryClient?.()))
2827

29-
const observer = $derived(
28+
// svelte-ignore state_referenced_locally - intentional, initial value
29+
let observer = $state(
30+
// svelte-ignore state_referenced_locally - intentional, initial value
3031
new MutationObserver<TData, TError, TVariables, TContext>(
3132
client,
3233
options(),
3334
),
3435
)
3536

36-
const mutate = $state<
37-
CreateMutateFunction<TData, TError, TVariables, TContext>
38-
>((variables, mutateOptions) => {
39-
observer.mutate(variables, mutateOptions).catch(noop)
40-
})
37+
watchChanges(
38+
() => client,
39+
'pre',
40+
() => {
41+
observer = new MutationObserver(client, options())
42+
},
43+
)
4144

4245
$effect.pre(() => {
4346
observer.setOptions(options())
4447
})
4548

46-
const result = $state(observer.getCurrentResult())
47-
48-
const unsubscribe = observer.subscribe((val) => {
49-
notifyManager.batchCalls(() => {
50-
Object.assign(result, val)
51-
})()
49+
const mutate = <CreateMutateFunction<TData, TError, TVariables, TContext>>((
50+
variables,
51+
mutateOptions,
52+
) => {
53+
observer.mutate(variables, mutateOptions).catch(noop)
5254
})
5355

54-
onDestroy(() => {
55-
unsubscribe()
56+
let result = $state(observer.getCurrentResult())
57+
watchChanges(
58+
() => observer,
59+
'pre',
60+
() => {
61+
result = observer.getCurrentResult()
62+
},
63+
)
64+
65+
$effect.pre(() => {
66+
const unsubscribe = observer.subscribe((val) => {
67+
notifyManager.batchCalls(() => {
68+
Object.assign(result, val)
69+
})()
70+
})
71+
return unsubscribe
5672
})
5773

74+
const resultProxy = $derived(
75+
new Proxy(result, {
76+
get: (_, prop) => {
77+
const r = {
78+
...result,
79+
mutate,
80+
mutateAsync: result.mutate,
81+
}
82+
if (prop == 'value') return r
83+
// @ts-expect-error
84+
return r[prop]
85+
},
86+
}),
87+
)
88+
5889
// @ts-expect-error
59-
return new Proxy(result, {
60-
get: (_, prop) => {
61-
const r = {
62-
...result,
63-
mutate,
64-
mutateAsync: result.mutate,
65-
}
66-
if (prop == 'value') return r
67-
// @ts-expect-error
68-
return r[prop]
69-
},
70-
})
90+
return resultProxy
7191
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { QueriesObserver } from '@tanstack/query-core'
2-
import { untrack } from 'svelte'
32
import { useIsRestoring } from './useIsRestoring.js'
43
import { createRawRef } from './containers.svelte.js'
54
import { useQueryClient } from './useQueryClient.js'
@@ -216,11 +215,12 @@ export function createQueries<
216215
}),
217216
)
218217

218+
// can't do same as createMutation, as QueriesObserver has no `setOptions` method
219219
const observer = $derived(
220220
new QueriesObserver<TCombinedResult>(
221221
client,
222-
untrack(() => resolvedQueryOptions),
223-
untrack(() => combine as QueriesObserverOptions<TCombinedResult>),
222+
resolvedQueryOptions,
223+
combine as QueriesObserverOptions<TCombinedResult>,
224224
),
225225
)
226226

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { untrack } from 'svelte'
2+
// modified from the great https://github.com/svecosystem/runed
3+
function runEffect(
4+
flush: 'post' | 'pre',
5+
effect: () => void | VoidFunction,
6+
): void {
7+
switch (flush) {
8+
case 'post':
9+
$effect(effect)
10+
break
11+
case 'pre':
12+
$effect.pre(effect)
13+
break
14+
}
15+
}
16+
type Getter<T> = () => T
17+
export const watchChanges = <T>(
18+
sources: Getter<T> | Array<Getter<T>>,
19+
flush: 'post' | 'pre',
20+
effect: (
21+
values: T | Array<T>,
22+
previousValues: T | undefined | Array<T | undefined>,
23+
) => void,
24+
) => {
25+
let active = false
26+
let previousValues: T | undefined | Array<T | undefined> = Array.isArray(
27+
sources,
28+
)
29+
? []
30+
: undefined
31+
runEffect(flush, () => {
32+
const values = Array.isArray(sources)
33+
? sources.map((source) => source())
34+
: sources()
35+
if (!active) {
36+
active = true
37+
previousValues = values
38+
return
39+
}
40+
const cleanup = untrack(() => effect(values, previousValues))
41+
previousValues = values
42+
return cleanup
43+
})
44+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script lang="ts">
2+
import { QueryClient } from '@tanstack/query-core'
3+
import { QueryClientProvider } from '../src/index.js'
4+
import { Snippet } from 'svelte'
5+
6+
const {
7+
queryClient = new QueryClient(),
8+
children,
9+
}: { queryClient?: QueryClient; children: Snippet } = $props()
10+
</script>
11+
12+
<QueryClientProvider client={queryClient}>
13+
{@render children()}
14+
</QueryClientProvider>

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,4 +1890,31 @@ describe('createQuery', () => {
18901890
expect(query.error?.message).toBe('Local Error')
18911891
}),
18921892
)
1893+
1894+
it(
1895+
'should support changing provided query client',
1896+
withEffectRoot(async () => {
1897+
const queryClient1 = new QueryClient()
1898+
const queryClient2 = new QueryClient()
1899+
1900+
let queryClient = $state(queryClient1)
1901+
1902+
const key = ['test']
1903+
1904+
createQuery(
1905+
() => ({
1906+
queryKey: key,
1907+
queryFn: () => Promise.resolve('prefetched'),
1908+
}),
1909+
() => queryClient,
1910+
)
1911+
1912+
expect(queryClient1.getQueryCache().find({ queryKey: key })).toBeDefined()
1913+
1914+
queryClient = queryClient2
1915+
flushSync()
1916+
1917+
expect(queryClient2.getQueryCache().find({ queryKey: key })).toBeDefined()
1918+
}),
1919+
)
18931920
})
Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,11 @@
11
<script lang="ts">
2-
import { QueryClient } from '@tanstack/query-core'
3-
import { createQuery, useIsFetching } from '../../src/index.js'
4-
import { sleep } from '@tanstack/query-test-utils'
5-
6-
const queryClient = new QueryClient()
7-
let ready = $state(false)
8-
9-
const isFetching = useIsFetching(undefined, queryClient)
10-
11-
const query = createQuery(
12-
() => ({
13-
queryKey: ['test'],
14-
queryFn: () => sleep(10).then(() => 'test'),
15-
enabled: ready,
16-
}),
17-
() => queryClient,
18-
)
2+
import ProviderWrapper from '../ProviderWrapper.svelte'
3+
import FetchStatus from './FetchStatus.svelte'
4+
import Query from './Query.svelte'
195
</script>
206

21-
<button onclick={() => (ready = true)}>setReady</button>
7+
<ProviderWrapper>
8+
<FetchStatus />
229

23-
<div>isFetching: {isFetching.current}</div>
24-
<div>Data: {query.data ?? 'undefined'}</div>
10+
<Query />
11+
</ProviderWrapper>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script lang="ts">
2+
import { useIsFetching } from '../../src/index.js'
3+
const isFetching = useIsFetching()
4+
</script>
5+
6+
<div>isFetching: {isFetching.current}</div>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script lang="ts">
2+
import { createQuery } from '../../src/index.js'
3+
import { sleep } from '@tanstack/query-test-utils'
4+
5+
let ready = $state(false)
6+
7+
const query = createQuery(() => ({
8+
queryKey: ['test'],
9+
queryFn: async () => {
10+
await sleep(10)
11+
return 'test'
12+
},
13+
enabled: ready,
14+
}))
15+
</script>
16+
17+
<button onclick={() => (ready = true)}>setReady</button>
18+
19+
<div>Data: {query.data ?? 'undefined'}</div>
Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
<script lang="ts">
2-
import { QueryClient } from '@tanstack/query-core'
3-
import { createMutation, useIsMutating } from '../../src/index.js'
4-
import { sleep } from '@tanstack/query-test-utils'
5-
6-
const queryClient = new QueryClient()
7-
const isMutating = useIsMutating(undefined, queryClient)
8-
9-
const mutation = createMutation(
10-
() => ({
11-
mutationKey: ['mutation-1'],
12-
mutationFn: () => sleep(10).then(() => 'data'),
13-
}),
14-
() => queryClient,
15-
)
2+
import ProviderWrapper from '../ProviderWrapper.svelte'
3+
import MutatingStatus from './MutatingStatus.svelte'
4+
import Query from './Query.svelte'
165
</script>
176

18-
<button onclick={() => mutation.mutate()}>Trigger</button>
7+
<ProviderWrapper>
8+
<MutatingStatus />
199

20-
<div>isMutating: {isMutating.current}</div>
10+
<Query />
11+
</ProviderWrapper>

0 commit comments

Comments
 (0)