@sanity/sdk-react 2.18.0 → 3.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/index.d.ts +267 -112
  2. package/dist/index.js +131 -89
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -9
  5. package/src/_exports/sdk-react.ts +14 -1
  6. package/src/components/auth/AuthBoundary.tsx +10 -5
  7. package/src/context/WorkbenchTokenRefresh.test.tsx +106 -0
  8. package/src/context/WorkbenchTokenRefresh.tsx +61 -0
  9. package/src/context/workbenchToken.ts +63 -0
  10. package/src/hooks/access/useCheckPermissions.test-d.ts +15 -0
  11. package/src/hooks/access/useCheckPermissions.test.tsx +53 -0
  12. package/src/hooks/access/useCheckPermissions.ts +24 -0
  13. package/src/hooks/applications/useApplication.test-d.ts +30 -0
  14. package/src/hooks/applications/useApplication.ts +22 -0
  15. package/src/hooks/applications/useApplications.test-d.ts +31 -0
  16. package/src/hooks/applications/useApplications.ts +25 -0
  17. package/src/hooks/applications/useCreateUserApplication.test-d.ts +14 -0
  18. package/src/hooks/applications/useCreateUserApplication.ts +11 -0
  19. package/src/hooks/applications/useDeleteApplication.test-d.ts +12 -0
  20. package/src/hooks/applications/useDeleteApplication.ts +11 -0
  21. package/src/hooks/applications/useDeleteUserApplication.test-d.ts +14 -0
  22. package/src/hooks/applications/useDeleteUserApplication.ts +11 -0
  23. package/src/hooks/applications/useUpdateApplication.test-d.ts +12 -0
  24. package/src/hooks/applications/useUpdateApplication.ts +11 -0
  25. package/src/hooks/applications/useUpdateUserApplication.test-d.ts +14 -0
  26. package/src/hooks/applications/useUpdateUserApplication.ts +11 -0
  27. package/src/hooks/applications/useUserApplication.test-d.ts +11 -0
  28. package/src/hooks/applications/useUserApplication.ts +14 -0
  29. package/src/hooks/applications/useUserApplications.test-d.ts +11 -0
  30. package/src/hooks/applications/useUserApplications.ts +14 -0
  31. package/src/hooks/helpers/createFetcherHook.test.tsx +180 -0
  32. package/src/hooks/helpers/createFetcherHook.ts +69 -0
  33. package/src/hooks/helpers/createMutationHook.test.tsx +125 -0
  34. package/src/hooks/helpers/createMutationHook.tsx +93 -0
  35. package/src/hooks/installations/useInstallation.test-d.ts +19 -0
  36. package/src/hooks/installations/useInstallation.ts +22 -0
  37. package/src/hooks/installations/useInstallations.test-d.ts +26 -0
  38. package/src/hooks/installations/useInstallations.ts +25 -0
  39. package/src/hooks/organizations/useOrganization.test-d.ts +19 -12
  40. package/src/hooks/organizations/useOrganization.test.ts +50 -52
  41. package/src/hooks/organizations/useOrganization.ts +13 -19
  42. package/src/hooks/organizations/useOrganizations.test-d.ts +26 -13
  43. package/src/hooks/organizations/useOrganizations.test.ts +49 -71
  44. package/src/hooks/organizations/useOrganizations.ts +14 -20
  45. package/src/hooks/projects/useProject.test-d.ts +18 -11
  46. package/src/hooks/projects/useProject.test.tsx +29 -23
  47. package/src/hooks/projects/useProject.ts +12 -16
  48. package/src/hooks/projects/useProjects.test-d.ts +22 -11
  49. package/src/hooks/projects/useProjects.test.ts +45 -98
  50. package/src/hooks/projects/useProjects.ts +16 -17
  51. package/src/hooks/dashboard/useDispatchIntent.test.ts +0 -251
  52. package/src/hooks/dashboard/useDispatchIntent.ts +0 -157
  53. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.test.ts +0 -128
  54. package/src/hooks/dashboard/utils/useResourceIdFromDocumentHandle.ts +0 -42
@@ -0,0 +1,12 @@
1
+ import {type Application, type UpdateApplicationInput} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type MutationHookResult} from '../helpers/createMutationHook'
5
+ import {useUpdateApplication} from './useUpdateApplication'
6
+
7
+ test('useUpdateApplication — returns the mutation envelope', () => {
8
+ const result = useUpdateApplication()
9
+ expectTypeOf(result).toEqualTypeOf<MutationHookResult<UpdateApplicationInput, Application>>()
10
+ expectTypeOf(result.mutate).parameter(0).toEqualTypeOf<UpdateApplicationInput>()
11
+ expectTypeOf(result.data).toEqualTypeOf<Application | undefined>()
12
+ })
@@ -0,0 +1,11 @@
1
+ import {updateApplication} from '@sanity/sdk'
2
+
3
+ import {createMutationHook} from '../helpers/createMutationHook'
4
+
5
+ /**
6
+ * Updates an application's mutable properties (title, icon, visibility).
7
+ *
8
+ * @internal
9
+ * @returns The mutation envelope `{mutate, isPending, error, data, reset}`.
10
+ */
11
+ export const useUpdateApplication = createMutationHook(updateApplication)
@@ -0,0 +1,14 @@
1
+ import {type UpdateUserApplicationInput, type UserApplication} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type MutationHookResult} from '../helpers/createMutationHook'
5
+ import {useUpdateUserApplication} from './useUpdateUserApplication'
6
+
7
+ test('useUpdateUserApplication — returns the mutation envelope', () => {
8
+ const result = useUpdateUserApplication()
9
+ expectTypeOf(result).toEqualTypeOf<
10
+ MutationHookResult<UpdateUserApplicationInput, UserApplication>
11
+ >()
12
+ expectTypeOf(result.mutate).parameter(0).toEqualTypeOf<UpdateUserApplicationInput>()
13
+ expectTypeOf(result.data).toEqualTypeOf<UserApplication | undefined>()
14
+ })
@@ -0,0 +1,11 @@
1
+ import {updateUserApplication} from '@sanity/sdk'
2
+
3
+ import {createMutationHook} from '../helpers/createMutationHook'
4
+
5
+ /**
6
+ * Updates a user application.
7
+ *
8
+ * @internal
9
+ * @returns The mutation envelope `{mutate, isPending, error, data, reset}`.
10
+ */
11
+ export const useUpdateUserApplication = createMutationHook(updateUserApplication)
@@ -0,0 +1,11 @@
1
+ import {type UserApplication} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type FetcherHookResult} from '../helpers/createFetcherHook'
5
+ import {useUserApplication} from './useUserApplication'
6
+
7
+ test('useUserApplication — returns a single user application in the result envelope', () => {
8
+ const result = useUserApplication('user_app_1')
9
+ expectTypeOf(result).toEqualTypeOf<FetcherHookResult<UserApplication>>()
10
+ expectTypeOf(result.data).toEqualTypeOf<UserApplication>()
11
+ })
@@ -0,0 +1,14 @@
1
+ import {userApplication} from '@sanity/sdk'
2
+
3
+ import {createFetcherHook} from '../helpers/createFetcherHook'
4
+
5
+ /**
6
+ * Returns a single user application by id.
7
+ *
8
+ * The hook suspends until the first fetch succeeds, so `data` is always present.
9
+ *
10
+ * @public
11
+ * @param userApplicationId - The user application id.
12
+ * @returns The result envelope `{data, isFetching, error, refetch}`.
13
+ */
14
+ export const useUserApplication = createFetcherHook(userApplication)
@@ -0,0 +1,11 @@
1
+ import {type UserApplication} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type FetcherHookResult} from '../helpers/createFetcherHook'
5
+ import {useUserApplications} from './useUserApplications'
6
+
7
+ test('useUserApplications — returns the user applications in the result envelope', () => {
8
+ const result = useUserApplications({organizationId: 'org_1'})
9
+ expectTypeOf(result).toEqualTypeOf<FetcherHookResult<UserApplication[]>>()
10
+ expectTypeOf(result.data).toEqualTypeOf<UserApplication[]>()
11
+ })
@@ -0,0 +1,14 @@
1
+ import {userApplications} from '@sanity/sdk'
2
+
3
+ import {createFetcherHook} from '../helpers/createFetcherHook'
4
+
5
+ /**
6
+ * Returns the current user's applications for the given organisation.
7
+ *
8
+ * The hook suspends until the first fetch succeeds, so `data` is always present.
9
+ *
10
+ * @public
11
+ * @param options - Options identifying the organisation.
12
+ * @returns The result envelope `{data, isFetching, error, refetch}`.
13
+ */
14
+ export const useUserApplications = createFetcherHook(userApplications)
@@ -0,0 +1,180 @@
1
+ import {createSanityInstance} from '@sanity/sdk'
2
+ import {defineFetcher, type Fetcher, type FetcherSnapshot} from '@sanity/sdk/_internal'
3
+ import {renderHook} from '@testing-library/react'
4
+ import {of, throwError} from 'rxjs'
5
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
6
+
7
+ import {useSanityInstance} from '../context/useSanityInstance'
8
+ import {createFetcherHook} from './createFetcherHook'
9
+
10
+ vi.mock('../context/useSanityInstance', () => ({
11
+ useSanityInstance: vi.fn(),
12
+ }))
13
+
14
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
15
+
16
+ const makeFetcher = (snapshot: FetcherSnapshot<string>): Fetcher<[id: string], string> => {
17
+ const fetcher = {
18
+ getState: vi.fn(() => ({
19
+ subscribe: vi.fn(() => () => {}),
20
+ getCurrent: () => snapshot,
21
+ observable: throwError(() => new Error('unexpected usage of observable')),
22
+ })),
23
+ resolveState: vi.fn(() => Promise.resolve('resolved')),
24
+ refetch: vi.fn(() => Promise.resolve('refetched')),
25
+ invalidate: vi.fn(),
26
+ invalidateAll: vi.fn(),
27
+ setData: vi.fn(() => ({undo: vi.fn()})),
28
+ }
29
+ return fetcher as unknown as Fetcher<[id: string], string>
30
+ }
31
+
32
+ const success: FetcherSnapshot<string> = {
33
+ status: 'success',
34
+ data: 'DATA',
35
+ error: undefined,
36
+ isFetching: false,
37
+ dataUpdatedAt: 1,
38
+ }
39
+
40
+ describe('createFetcherHook', () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks()
43
+ vi.mocked(useSanityInstance).mockReturnValue(instance)
44
+ })
45
+
46
+ it('returns {data, isFetching, error, refetch} from a success snapshot', () => {
47
+ const fetcher = makeFetcher(success)
48
+ const useThing = createFetcherHook(fetcher)
49
+ const {result} = renderHook(() => useThing('a'))
50
+
51
+ expect(result.current.data).toBe('DATA')
52
+ expect(result.current.isFetching).toBe(false)
53
+ expect(result.current.error).toBeUndefined()
54
+ expect(typeof result.current.refetch).toBe('function')
55
+ })
56
+
57
+ it('surfaces isFetching and the background error on the success arm', () => {
58
+ const boom = new Error('background')
59
+ const fetcher = makeFetcher({...success, isFetching: true, error: boom})
60
+ const useThing = createFetcherHook(fetcher)
61
+ const {result} = renderHook(() => useThing('a'))
62
+
63
+ expect(result.current.isFetching).toBe(true)
64
+ expect(result.current.error).toBe(boom)
65
+ })
66
+
67
+ it('refetch() calls the fetcher with the instance and params', () => {
68
+ const fetcher = makeFetcher(success)
69
+ const useThing = createFetcherHook(fetcher)
70
+ const {result} = renderHook(() => useThing('a'))
71
+
72
+ const returned = result.current.refetch()
73
+
74
+ expect(fetcher.refetch).toHaveBeenCalledWith(instance, 'a')
75
+ expect(returned).toBeInstanceOf(Promise)
76
+ })
77
+
78
+ it('suspends on a pending snapshot by throwing resolveState', () => {
79
+ const fetcher = makeFetcher({
80
+ status: 'pending',
81
+ data: undefined,
82
+ error: undefined,
83
+ isFetching: true,
84
+ dataUpdatedAt: undefined,
85
+ })
86
+ const useThing = createFetcherHook(fetcher)
87
+ const {result} = renderHook(() => {
88
+ try {
89
+ return useThing('a')
90
+ } catch (thrown) {
91
+ return thrown
92
+ }
93
+ })
94
+
95
+ expect(fetcher.resolveState).toHaveBeenCalledWith(instance, 'a')
96
+ expect(result.current).toBe(vi.mocked(fetcher.resolveState).mock.results[0]!.value)
97
+ })
98
+
99
+ it('throws the error arm to the error boundary', () => {
100
+ const boom = new Error('fatal')
101
+ const fetcher = makeFetcher({
102
+ status: 'error',
103
+ data: undefined,
104
+ error: boom,
105
+ isFetching: false,
106
+ dataUpdatedAt: undefined,
107
+ })
108
+ const useThing = createFetcherHook(fetcher)
109
+ const {result} = renderHook(() => {
110
+ try {
111
+ return useThing('a')
112
+ } catch (thrown) {
113
+ return thrown
114
+ }
115
+ })
116
+
117
+ expect(result.current).toBe(boom)
118
+ })
119
+
120
+ // Regression: the mocked fetcher above can never reproduce the real bug — its
121
+ // `getCurrent` returns a fixed snapshot object. A real fetcher store replaces
122
+ // the cache entry on every (un)subscribe, so `getCurrent` yields a new snapshot
123
+ // reference each time. Feeding that straight through `useSyncExternalStore`
124
+ // makes React see the store "change" on every commit and re-render forever
125
+ // ("Maximum update depth exceeded", React #185).
126
+ it('does not re-render forever over a real fetcher store (SDK-1448)', () => {
127
+ const fetcher = defineFetcher<[id: string], string>({
128
+ name: `regression-loop-${Math.random().toString(36).slice(2)}`,
129
+ getKey: (_instance, id) => id,
130
+ fetch: () => (id) => of(`DATA:${id}`),
131
+ })
132
+ // Seed a success entry so we skip Suspense and land in useSyncExternalStore.
133
+ fetcher.setData(instance, ['a'], 'DATA:a')
134
+
135
+ const useThing = createFetcherHook(fetcher)
136
+
137
+ let renders = 0
138
+ const {result} = renderHook(() => {
139
+ renders += 1
140
+ if (renders > 25) throw new Error(`infinite render loop: ${renders} renders`)
141
+ return useThing('a')
142
+ })
143
+
144
+ expect(result.current.data).toBe('DATA:a')
145
+ expect(renders).toBeLessThan(25)
146
+ })
147
+
148
+ // The realistic case: `data` is an object, not a primitive. The store hands
149
+ // back a fresh snapshot object on every (un)subscribe, so the dedup must keep
150
+ // returning the *same* `data` reference while the entry is unchanged —
151
+ // otherwise consumers that key off `data` identity (memo deps, effects)
152
+ // re-render on every commit even though nothing changed. This is the property
153
+ // the snapshot-equality check guards.
154
+ it('keeps a stable object data reference across re-renders (SDK-1448)', () => {
155
+ const data = {id: 'a', title: 'Thing', tags: ['x', 'y']}
156
+ const fetcher = defineFetcher<[id: string], typeof data>({
157
+ name: `regression-object-${Math.random().toString(36).slice(2)}`,
158
+ getKey: (_instance, id) => id,
159
+ fetch: () => () => of(data),
160
+ })
161
+ fetcher.setData(instance, ['a'], data)
162
+
163
+ const useThing = createFetcherHook(fetcher)
164
+
165
+ let renders = 0
166
+ const seen: unknown[] = []
167
+ const {result} = renderHook(() => {
168
+ renders += 1
169
+ if (renders > 25) throw new Error(`infinite render loop: ${renders} renders`)
170
+ const value = useThing('a')
171
+ seen.push(value.data)
172
+ return value
173
+ })
174
+
175
+ expect(result.current.data).toBe(data)
176
+ expect(renders).toBeLessThan(25)
177
+ // Every commit observed the identical object — no churn from fresh snapshots.
178
+ expect(seen.every((d) => d === data)).toBe(true)
179
+ })
180
+ })
@@ -0,0 +1,69 @@
1
+ import {type Fetcher, type FetcherSnapshot, isDeepEqual} from '@sanity/sdk/_internal'
2
+ import {useRef, useSyncExternalStore} from 'react'
3
+
4
+ import {useSanityInstance} from '../context/useSanityInstance'
5
+
6
+ /**
7
+ * The value returned by a fetcher-backed hook. The hook suspends until the
8
+ * first fetch succeeds, so `data` is always present once your component renders.
9
+ *
10
+ * @public
11
+ */
12
+ export interface FetcherHookResult<TData> {
13
+ /** The resolved data. Guaranteed present — the hook suspends until the first fetch succeeds. */
14
+ data: TData
15
+ /** A fetch for this entry is in flight (background revalidation or a `refetch()`). */
16
+ isFetching: boolean
17
+ /** The most recent background-fetch failure while data still renders; cleared by the next success. */
18
+ error: unknown
19
+ /** Imperatively refetch, bypassing staleness. Resolves with the refreshed data. */
20
+ refetch: () => Promise<TData>
21
+ }
22
+
23
+ /**
24
+ * Builds a Suspense hook over a {@link Fetcher}: it suspends until the first
25
+ * success via the snapshot's `status`, throws the error arm to the nearest error
26
+ * boundary, and returns the live `{data, isFetching, error, refetch}` envelope.
27
+ *
28
+ * @internal
29
+ */
30
+ export function createFetcherHook<TParams extends unknown[], TData>(
31
+ fetcher: Fetcher<TParams, TData>,
32
+ ): (...params: TParams) => FetcherHookResult<TData> {
33
+ return function useFetcherHook(...params: TParams): FetcherHookResult<TData> {
34
+ const instance = useSanityInstance()
35
+ const source = fetcher.getState(instance, ...params)
36
+
37
+ // No entry/data yet — suspend on the first fetch (matches an 'error' below never reaching here).
38
+ if (source.getCurrent().status === 'pending') {
39
+ throw fetcher.resolveState(instance, ...params)
40
+ }
41
+
42
+ // The store replaces its cache entry on every (un)subscribe (it bumps a
43
+ // subscription counter), so `getCurrent()` hands back a fresh snapshot object
44
+ // with identical fields on each commit. Passed raw to `useSyncExternalStore`
45
+ // — which compares by `Object.is` — that reads as a perpetual change and
46
+ // re-renders forever. Reuse the previous snapshot while its fields are
47
+ // unchanged so React sees a stable reference.
48
+ const previous = useRef<FetcherSnapshot<TData> | null>(null)
49
+ const snapshot = useSyncExternalStore(source.subscribe, () => {
50
+ const next = source.getCurrent()
51
+ const prev = previous.current
52
+ if (prev && isDeepEqual(prev, next)) return prev
53
+ previous.current = next
54
+ return next
55
+ })
56
+
57
+ if (snapshot.status !== 'success') {
58
+ // 'pending' already suspended above; 'error' surfaces to the error boundary.
59
+ throw snapshot.status === 'error' ? snapshot.error : fetcher.resolveState(instance, ...params)
60
+ }
61
+
62
+ return {
63
+ data: snapshot.data,
64
+ isFetching: snapshot.isFetching,
65
+ error: snapshot.error,
66
+ refetch: () => fetcher.refetch(instance, ...params),
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,125 @@
1
+ import {createSanityInstance} from '@sanity/sdk'
2
+ import {type MutationResult} from '@sanity/sdk/_internal'
3
+ import {act, renderHook} from '@testing-library/react'
4
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
5
+
6
+ import {useSanityInstance} from '../context/useSanityInstance'
7
+ import {createMutationHook} from './createMutationHook'
8
+
9
+ vi.mock('../context/useSanityInstance', () => ({
10
+ useSanityInstance: vi.fn(),
11
+ }))
12
+
13
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
14
+
15
+ const settled = <T,>(data: T): MutationResult<T> => ({data, invalidated: Promise.resolve()})
16
+
17
+ describe('createMutationHook', () => {
18
+ beforeEach(() => {
19
+ vi.clearAllMocks()
20
+ vi.mocked(useSanityInstance).mockReturnValue(instance)
21
+ })
22
+
23
+ it('starts idle', () => {
24
+ const useThing = createMutationHook(vi.fn())
25
+ const {result} = renderHook(() => useThing())
26
+
27
+ expect(result.current.isPending).toBe(false)
28
+ expect(result.current.data).toBeUndefined()
29
+ expect(result.current.error).toBeUndefined()
30
+ })
31
+
32
+ it('calls the mutation with the instance and resolves with the server data', async () => {
33
+ const mutation = vi.fn((_instance, input: string) =>
34
+ Promise.resolve(settled(`created:${input}`)),
35
+ )
36
+ const useThing = createMutationHook(mutation)
37
+ const {result} = renderHook(() => useThing())
38
+
39
+ let resolved: string | undefined
40
+ await act(async () => {
41
+ resolved = await result.current.mutate('a')
42
+ })
43
+
44
+ expect(mutation).toHaveBeenCalledWith(instance, 'a')
45
+ expect(resolved).toBe('created:a')
46
+ expect(result.current.data).toBe('created:a')
47
+ expect(result.current.error).toBeUndefined()
48
+ expect(result.current.isPending).toBe(false)
49
+ })
50
+
51
+ it('reports isPending while the mutation is in flight', async () => {
52
+ let release!: (value: MutationResult<string>) => void
53
+ const mutation = vi.fn(
54
+ () => new Promise<MutationResult<string>>((resolve) => (release = resolve)),
55
+ )
56
+ const useThing = createMutationHook(mutation)
57
+ const {result} = renderHook(() => useThing())
58
+
59
+ act(() => {
60
+ void result.current.mutate('a')
61
+ })
62
+ expect(result.current.isPending).toBe(true)
63
+
64
+ await act(async () => {
65
+ release(settled('done'))
66
+ })
67
+ expect(result.current.isPending).toBe(false)
68
+ expect(result.current.data).toBe('done')
69
+ })
70
+
71
+ it('rejects on failure and captures the error in state', async () => {
72
+ const boom = new Error('nope')
73
+ const mutation = vi.fn(() => Promise.reject(boom))
74
+ const useThing = createMutationHook(mutation)
75
+ const {result} = renderHook(() => useThing())
76
+
77
+ await act(async () => {
78
+ await expect(result.current.mutate('a')).rejects.toBe(boom)
79
+ })
80
+
81
+ expect(result.current.error).toBe(boom)
82
+ expect(result.current.data).toBeUndefined()
83
+ expect(result.current.isPending).toBe(false)
84
+ })
85
+
86
+ it('reset() returns to idle', async () => {
87
+ const mutation = vi.fn((_instance, input: string) => Promise.resolve(settled(input)))
88
+ const useThing = createMutationHook(mutation)
89
+ const {result} = renderHook(() => useThing())
90
+
91
+ await act(async () => {
92
+ await result.current.mutate('a')
93
+ })
94
+ expect(result.current.data).toBe('a')
95
+
96
+ act(() => result.current.reset())
97
+ expect(result.current.data).toBeUndefined()
98
+ expect(result.current.error).toBeUndefined()
99
+ expect(result.current.isPending).toBe(false)
100
+ })
101
+
102
+ it('only the latest call writes state, so a stale call cannot clobber it', async () => {
103
+ const releases: Array<(value: MutationResult<string>) => void> = []
104
+ const mutation = vi.fn(
105
+ () => new Promise<MutationResult<string>>((resolve) => releases.push(resolve)),
106
+ )
107
+ const useThing = createMutationHook(mutation)
108
+ const {result} = renderHook(() => useThing())
109
+
110
+ let first!: Promise<string>
111
+ let second!: Promise<string>
112
+ act(() => {
113
+ first = result.current.mutate('first')
114
+ second = result.current.mutate('second')
115
+ })
116
+
117
+ await act(async () => {
118
+ releases[1]!(settled('second-result')) // latest resolves first
119
+ releases[0]!(settled('first-result')) // stale resolves after
120
+ await Promise.all([first, second])
121
+ })
122
+
123
+ expect(result.current.data).toBe('second-result')
124
+ })
125
+ })
@@ -0,0 +1,93 @@
1
+ import {type SanityInstance} from '@sanity/sdk'
2
+ import {type MutationResult} from '@sanity/sdk/_internal'
3
+ import {useCallback, useRef, useState} from 'react'
4
+
5
+ import {useSanityInstance} from '../context/useSanityInstance'
6
+
7
+ /**
8
+ * The value returned by a mutation-backed hook. The write-side counterpart to
9
+ * {@link FetcherHookResult}: local `{mutate, isPending, error, data, reset}`
10
+ * state around a core mutation action.
11
+ *
12
+ * @public
13
+ */
14
+ export interface MutationHookResult<TInput, TResult> {
15
+ /**
16
+ * Runs the mutation. Resolves with the server response; rejects on failure
17
+ * (the failure is also captured in {@link MutationHookResult.error}, so a
18
+ * fire-and-forget caller should read `error` and an `await`ing caller should
19
+ * `try`/`catch`).
20
+ */
21
+ mutate: (input: TInput) => Promise<TResult>
22
+ /** A mutation is currently in flight. */
23
+ isPending: boolean
24
+ /** The most recent failure; cleared by the next `mutate` or `reset`. */
25
+ error: unknown
26
+ /** The last successful server response, or `undefined` before the first success. */
27
+ data: TResult | undefined
28
+ /** Clears state back to idle and abandons any in-flight result. */
29
+ reset: () => void
30
+ }
31
+
32
+ type MutationState<TResult> =
33
+ | {status: 'idle'; data: undefined; error: undefined}
34
+ | {status: 'pending'; data: TResult | undefined; error: undefined}
35
+ | {status: 'success'; data: TResult; error: undefined}
36
+ | {status: 'error'; data: TResult | undefined; error: unknown}
37
+
38
+ /**
39
+ * Builds a hook over a core mutation action (the result of `defineMutation`).
40
+ * The write-side counterpart to {@link createFetcherHook}: it binds the action
41
+ * to the current {@link SanityInstance} and tracks `{isPending, error, data}`
42
+ * locally. `mutate` resolves with the server response only — the mutation's
43
+ * `invalidated` promise is intentionally not exposed, since cache
44
+ * reconciliation is handled by the fetcher stores.
45
+ *
46
+ * @internal
47
+ */
48
+ export function createMutationHook<TInput, TResult>(
49
+ mutation: (instance: SanityInstance, input: TInput) => Promise<MutationResult<TResult>>,
50
+ ): () => MutationHookResult<TInput, TResult> {
51
+ const idle: MutationState<TResult> = {status: 'idle', data: undefined, error: undefined}
52
+
53
+ return function useMutationHook(): MutationHookResult<TInput, TResult> {
54
+ const instance = useSanityInstance()
55
+ const [state, setState] = useState<MutationState<TResult>>(idle)
56
+ // Increments per call; only the latest call is allowed to write state, so an
57
+ // earlier in-flight mutation can't clobber a later one (or a `reset`).
58
+ const callId = useRef(0)
59
+
60
+ const mutate = useCallback(
61
+ (input: TInput): Promise<TResult> => {
62
+ const id = ++callId.current
63
+ setState((prev) => ({status: 'pending', data: prev.data, error: undefined}))
64
+ return mutation(instance, input).then(
65
+ ({data}) => {
66
+ if (id === callId.current) setState({status: 'success', data, error: undefined})
67
+ return data
68
+ },
69
+ (error: unknown) => {
70
+ if (id === callId.current) {
71
+ setState((prev) => ({status: 'error', data: prev.data, error}))
72
+ }
73
+ throw error
74
+ },
75
+ )
76
+ },
77
+ [instance],
78
+ )
79
+
80
+ const reset = useCallback(() => {
81
+ callId.current++ // abandon any in-flight response so it can't revive stale state
82
+ setState(idle)
83
+ }, [])
84
+
85
+ return {
86
+ mutate,
87
+ isPending: state.status === 'pending',
88
+ error: state.error,
89
+ data: state.data,
90
+ reset,
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,19 @@
1
+ import {type Installation, type InstallationActiveConfig} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type FetcherHookResult} from '../helpers/createFetcherHook'
5
+ import {useInstallation} from './useInstallation'
6
+
7
+ test('useInstallation — no include: the base installation', () => {
8
+ const result = useInstallation('inst_1')
9
+ expectTypeOf(result).toEqualTypeOf<FetcherHookResult<Installation<never>>>()
10
+ expectTypeOf<
11
+ Extract<keyof typeof result.data, 'activeConfig' | 'access' | 'interfaces'>
12
+ >().toEqualTypeOf<never>()
13
+ })
14
+
15
+ test('useInstallation — activeConfig include adds the config field', () => {
16
+ const result = useInstallation('inst_1', {include: ['activeConfig']})
17
+ expectTypeOf(result.data).toEqualTypeOf<Installation<'activeConfig'>>()
18
+ expectTypeOf(result.data.activeConfig).toEqualTypeOf<InstallationActiveConfig | null>()
19
+ })
@@ -0,0 +1,22 @@
1
+ import {type Installation, installation, type InstallationInclude} from '@sanity/sdk'
2
+
3
+ import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
4
+
5
+ /**
6
+ * Returns a single installation by id.
7
+ *
8
+ * The hook suspends until the first fetch succeeds, so `data` is always present.
9
+ * The `include` tokens you pass shape `data`: each requested token adds its
10
+ * field, and omitted ones are absent from the type.
11
+ *
12
+ * @public
13
+ * @param installationId - The installation id.
14
+ * @param options - Optional `include` list to expand related resources.
15
+ * @returns The result envelope `{data, isFetching, error, refetch}`.
16
+ */
17
+ export const useInstallation = createFetcherHook(installation) as <
18
+ Include extends InstallationInclude = never,
19
+ >(
20
+ installationId: string,
21
+ options?: {include?: Include[]},
22
+ ) => FetcherHookResult<Installation<Include>>
@@ -0,0 +1,26 @@
1
+ import {type Installation, type InstallationAccess, type InstallationsResponse} from '@sanity/sdk'
2
+ import {expectTypeOf, test} from 'vitest'
3
+
4
+ import {type FetcherHookResult} from '../helpers/createFetcherHook'
5
+ import {useInstallations} from './useInstallations'
6
+
7
+ test('useInstallations — no include: base items, no gated fields', () => {
8
+ const result = useInstallations({organizationId: 'org_1'})
9
+ expectTypeOf(result).toEqualTypeOf<FetcherHookResult<InstallationsResponse<never>>>()
10
+ type Item = (typeof result.data.data)[number]
11
+ expectTypeOf<
12
+ Extract<keyof Item, 'activeConfig' | 'access' | 'interfaces'>
13
+ >().toEqualTypeOf<never>()
14
+ })
15
+
16
+ test('useInstallations — include shapes each item', () => {
17
+ const result = useInstallations({organizationId: 'org_1', include: ['access']})
18
+ expectTypeOf(result.data.data).toEqualTypeOf<Installation<'access'>[]>()
19
+ type Item = (typeof result.data.data)[number]
20
+ expectTypeOf<Item['access']>().toEqualTypeOf<InstallationAccess[]>()
21
+ })
22
+
23
+ test('useInstallations — requires an organizationId', () => {
24
+ // @ts-expect-error — organizationId is required
25
+ void useInstallations({})
26
+ })
@@ -0,0 +1,25 @@
1
+ import {
2
+ type InstallationInclude,
3
+ installations,
4
+ type InstallationsOptions,
5
+ type InstallationsResponse,
6
+ } from '@sanity/sdk'
7
+
8
+ import {createFetcherHook, type FetcherHookResult} from '../helpers/createFetcherHook'
9
+
10
+ /**
11
+ * Returns the installations matching the given options.
12
+ *
13
+ * The hook suspends until the first fetch succeeds, so `data` is always present.
14
+ * The `include` tokens you pass shape `data.data`: each requested token adds its
15
+ * field, and omitted ones are absent from the type.
16
+ *
17
+ * @public
18
+ * @param options - Filter and include options for the installations list.
19
+ * @returns The result envelope `{data, isFetching, error, refetch}`.
20
+ */
21
+ export const useInstallations = createFetcherHook(installations) as <
22
+ Include extends InstallationInclude = never,
23
+ >(
24
+ options: InstallationsOptions<Include>,
25
+ ) => FetcherHookResult<InstallationsResponse<Include>>