@sanity/sdk-react 0.0.0-alpha.21 → 0.0.0-alpha.23

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 (71) hide show
  1. package/dist/index.d.ts +502 -3460
  2. package/dist/index.js +400 -465
  3. package/dist/index.js.map +1 -1
  4. package/package.json +17 -15
  5. package/src/_exports/index.ts +4 -5
  6. package/src/components/SDKProvider.test.tsx +78 -54
  7. package/src/components/SDKProvider.tsx +31 -26
  8. package/src/components/SanityApp.test.tsx +121 -15
  9. package/src/components/SanityApp.tsx +26 -15
  10. package/src/components/auth/AuthBoundary.test.tsx +32 -14
  11. package/src/components/auth/AuthBoundary.tsx +53 -23
  12. package/src/components/auth/LoginCallback.test.tsx +19 -6
  13. package/src/components/auth/LoginCallback.tsx +2 -11
  14. package/src/components/auth/LoginError.test.tsx +12 -4
  15. package/src/components/auth/LoginError.tsx +13 -21
  16. package/src/components/auth/LoginFooter.test.tsx +7 -3
  17. package/src/context/ResourceProvider.test.tsx +157 -0
  18. package/src/context/ResourceProvider.tsx +111 -0
  19. package/src/context/SanityInstanceContext.ts +1 -1
  20. package/src/hooks/auth/useLoginUrl.tsx +14 -0
  21. package/src/hooks/client/useClient.ts +2 -1
  22. package/src/hooks/comlink/useManageFavorite.test.ts +16 -8
  23. package/src/hooks/comlink/useManageFavorite.ts +37 -13
  24. package/src/hooks/comlink/useRecordDocumentHistoryEvent.test.ts +8 -4
  25. package/src/hooks/comlink/useRecordDocumentHistoryEvent.ts +10 -8
  26. package/src/hooks/context/useSanityInstance.test.tsx +157 -15
  27. package/src/hooks/context/useSanityInstance.ts +66 -26
  28. package/src/hooks/dashboard/useNavigateToStudioDocument.test.ts +13 -31
  29. package/src/hooks/dashboard/useNavigateToStudioDocument.ts +12 -15
  30. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.test.tsx → useStudioWorkspacesByProjectIdDataset.test.tsx} +13 -13
  31. package/src/hooks/dashboard/{useStudioWorkspacesByResourceId.ts → useStudioWorkspacesByProjectIdDataset.ts} +10 -9
  32. package/src/hooks/datasets/useDatasets.ts +15 -4
  33. package/src/hooks/document/useApplyDocumentActions.test.ts +4 -9
  34. package/src/hooks/document/useApplyDocumentActions.ts +6 -31
  35. package/src/hooks/document/useDocument.test.ts +2 -2
  36. package/src/hooks/document/useDocument.ts +40 -19
  37. package/src/hooks/document/useDocumentEvent.test.ts +2 -3
  38. package/src/hooks/document/useDocumentEvent.ts +7 -11
  39. package/src/hooks/document/useDocumentPermissions.test.ts +204 -0
  40. package/src/hooks/document/useDocumentPermissions.ts +31 -23
  41. package/src/hooks/document/useDocumentSyncStatus.ts +5 -4
  42. package/src/hooks/document/useEditDocument.test.ts +2 -3
  43. package/src/hooks/document/useEditDocument.ts +43 -29
  44. package/src/hooks/documents/useDocuments.test.tsx +30 -3
  45. package/src/hooks/documents/useDocuments.ts +20 -7
  46. package/src/hooks/helpers/createCallbackHook.test.tsx +2 -2
  47. package/src/hooks/helpers/createCallbackHook.tsx +2 -3
  48. package/src/hooks/helpers/createStateSourceHook.test.tsx +1 -1
  49. package/src/hooks/helpers/createStateSourceHook.tsx +5 -8
  50. package/src/hooks/paginatedDocuments/usePaginatedDocuments.test.tsx +43 -18
  51. package/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +36 -50
  52. package/src/hooks/preview/usePreview.test.tsx +66 -7
  53. package/src/hooks/preview/usePreview.tsx +17 -12
  54. package/src/hooks/projection/useProjection.test.tsx +68 -3
  55. package/src/hooks/projection/useProjection.ts +21 -24
  56. package/src/hooks/projects/useProject.ts +7 -4
  57. package/src/hooks/query/useQuery.ts +32 -14
  58. package/src/hooks/users/useUsers.test.tsx +330 -0
  59. package/src/hooks/users/useUsers.ts +65 -52
  60. package/src/components/Login/LoginLinks.test.tsx +0 -90
  61. package/src/components/Login/LoginLinks.tsx +0 -58
  62. package/src/components/auth/Login.test.tsx +0 -27
  63. package/src/components/auth/Login.tsx +0 -39
  64. package/src/components/auth/LoginLayout.test.tsx +0 -19
  65. package/src/components/auth/LoginLayout.tsx +0 -69
  66. package/src/components/auth/authTestHelpers.tsx +0 -11
  67. package/src/context/SanityProvider.test.tsx +0 -25
  68. package/src/context/SanityProvider.tsx +0 -50
  69. package/src/hooks/auth/useLoginUrls.test.tsx +0 -68
  70. package/src/hooks/auth/useLoginUrls.tsx +0 -52
  71. package/src/hooks/users/useUsers.test.ts +0 -163
@@ -1,10 +1,10 @@
1
1
  import {AuthStateType} from '@sanity/sdk'
2
- import {useAuthState} from '@sanity/sdk-react'
3
- import {screen, waitFor} from '@testing-library/react'
2
+ import {render, screen, waitFor} from '@testing-library/react'
4
3
  import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'
5
4
 
5
+ import {ResourceProvider} from '../../context/ResourceProvider'
6
+ import {useAuthState} from '../../hooks/auth/useAuthState'
6
7
  import {AuthBoundary} from './AuthBoundary'
7
- import {renderWithWrappers} from './authTestHelpers'
8
8
 
9
9
  // Mock hooks
10
10
  vi.mock('../../hooks/auth/useAuthState', () => ({
@@ -46,27 +46,37 @@ describe('AuthBoundary', () => {
46
46
  consoleErrorSpy?.mockRestore()
47
47
  })
48
48
 
49
- it('renders the Login component when authState="logged-out"', () => {
49
+ it.skip('redirects to the sanity.io/login url when authState="logged-out"', async () => {
50
50
  vi.mocked(useAuthState).mockReturnValue({
51
51
  type: AuthStateType.LOGGED_OUT,
52
52
  isDestroyingSession: false,
53
53
  })
54
- renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
54
+ render(
55
+ <ResourceProvider fallback={null}>
56
+ <AuthBoundary>Protected Content</AuthBoundary>
57
+ </ResourceProvider>,
58
+ )
55
59
 
56
- // The login screen should show "Choose login provider" by default
57
- expect(screen.getByText('Choose login provider')).toBeInTheDocument()
58
- expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
60
+ // Wait for the redirect to happen
61
+ await waitFor(() => {
62
+ expect(window.location.href).toBe('https://sanity.io/login')
63
+ })
59
64
  })
60
65
 
61
- it('renders the LoginCallback component when authState="logging-in"', () => {
66
+ it('renders the empty LoginCallback component when authState="logging-in"', () => {
62
67
  vi.mocked(useAuthState).mockReturnValue({
63
68
  type: AuthStateType.LOGGING_IN,
64
69
  isExchangingToken: false,
65
70
  })
66
- renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
71
+ const {container} = render(
72
+ <ResourceProvider fallback={null}>
73
+ <AuthBoundary>Protected Content</AuthBoundary>
74
+ </ResourceProvider>,
75
+ )
67
76
 
68
- // The callback screen shows "Logging you in…"
69
- expect(screen.getByText('Logging you in…')).toBeInTheDocument()
77
+ // The callback screen renders null check that it renders nothing
78
+ expect(container.innerHTML).toBe('')
79
+ expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
70
80
  })
71
81
 
72
82
  it('renders children when authState="logged-in"', () => {
@@ -75,7 +85,11 @@ describe('AuthBoundary', () => {
75
85
  currentUser: null,
76
86
  token: 'exampleToken',
77
87
  })
78
- renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
88
+ render(
89
+ <ResourceProvider fallback={null}>
90
+ <AuthBoundary>Protected Content</AuthBoundary>
91
+ </ResourceProvider>,
92
+ )
79
93
 
80
94
  expect(screen.getByText('Protected Content')).toBeInTheDocument()
81
95
  })
@@ -85,7 +99,11 @@ describe('AuthBoundary', () => {
85
99
  type: AuthStateType.ERROR,
86
100
  error: new Error('test error'),
87
101
  })
88
- renderWithWrappers(<AuthBoundary>Protected Content</AuthBoundary>)
102
+ render(
103
+ <ResourceProvider fallback={null}>
104
+ <AuthBoundary>Protected Content</AuthBoundary>
105
+ </ResourceProvider>,
106
+ )
89
107
 
90
108
  // The AuthBoundary should throw an AuthError internally
91
109
  // and then display the LoginError component as the fallback.
@@ -1,14 +1,13 @@
1
1
  import {AuthStateType} from '@sanity/sdk'
2
- import {useMemo} from 'react'
2
+ import {useEffect, useMemo} from 'react'
3
3
  import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
4
4
 
5
5
  import {useAuthState} from '../../hooks/auth/useAuthState'
6
+ import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
6
7
  import {isInIframe} from '../utils'
7
8
  import {AuthError} from './AuthError'
8
- import {Login} from './Login'
9
9
  import {LoginCallback} from './LoginCallback'
10
10
  import {LoginError, type LoginErrorProps} from './LoginError'
11
- import {type LoginLayoutProps} from './LoginLayout'
12
11
 
13
12
  // Only import bridge if we're in an iframe. This assumes that the app is
14
13
  // running within SanityOS if it is in an iframe.
@@ -26,27 +25,42 @@ if (isInIframe()) {
26
25
  }
27
26
 
28
27
  /**
29
- * @internal
28
+ * @public
30
29
  */
31
- interface AuthBoundaryProps extends LoginLayoutProps {
30
+ export interface AuthBoundaryProps {
32
31
  /**
33
32
  * Custom component to render the login screen.
34
- * Receives all login layout props. Defaults to {@link Login}.
33
+ * Receives all props. Defaults to {@link Login}.
35
34
  */
36
- LoginComponent?: React.ComponentType<LoginLayoutProps>
35
+ LoginComponent?: React.ComponentType<{
36
+ header?: React.ReactNode
37
+ footer?: React.ReactNode
38
+ }>
37
39
 
38
40
  /**
39
41
  * Custom component to render during OAuth callback processing.
40
- * Receives all login layout props. Defaults to {@link LoginCallback}.
42
+ * Receives all props. Defaults to {@link LoginCallback}.
41
43
  */
42
- CallbackComponent?: React.ComponentType<LoginLayoutProps>
44
+ CallbackComponent?: React.ComponentType<{
45
+ header?: React.ReactNode
46
+ footer?: React.ReactNode
47
+ }>
43
48
 
44
49
  /**
45
50
  * Custom component to render when authentication errors occur.
46
- * Receives login layout props and error boundary props. Defaults to
51
+ * Receives error boundary props and layout props. Defaults to
47
52
  * {@link LoginError}
48
53
  */
49
54
  LoginErrorComponent?: React.ComponentType<LoginErrorProps>
55
+
56
+ /** Header content to display */
57
+ header?: React.ReactNode
58
+
59
+ /** Footer content to display */
60
+ footer?: React.ReactNode
61
+
62
+ /** Protected content to render when authenticated */
63
+ children?: React.ReactNode
50
64
  }
51
65
 
52
66
  /**
@@ -74,12 +88,11 @@ export function AuthBoundary({
74
88
  LoginErrorComponent = LoginError,
75
89
  ...props
76
90
  }: AuthBoundaryProps): React.ReactNode {
77
- const {header, footer} = props
78
91
  const FallbackComponent = useMemo(() => {
79
92
  return function LoginComponentWithLayoutProps(fallbackProps: FallbackProps) {
80
- return <LoginErrorComponent {...fallbackProps} header={header} footer={footer} />
93
+ return <LoginErrorComponent {...fallbackProps} />
81
94
  }
82
- }, [header, footer, LoginErrorComponent])
95
+ }, [LoginErrorComponent])
83
96
 
84
97
  return (
85
98
  <ErrorBoundary FallbackComponent={FallbackComponent}>
@@ -88,19 +101,32 @@ export function AuthBoundary({
88
101
  )
89
102
  }
90
103
 
91
- interface AuthSwitchProps extends LoginLayoutProps {
92
- LoginComponent?: React.ComponentType<LoginLayoutProps>
93
- CallbackComponent?: React.ComponentType<LoginLayoutProps>
104
+ interface AuthSwitchProps {
105
+ LoginComponent?: React.ComponentType<{
106
+ header?: React.ReactNode
107
+ footer?: React.ReactNode
108
+ }>
109
+ CallbackComponent?: React.ComponentType<{
110
+ header?: React.ReactNode
111
+ footer?: React.ReactNode
112
+ }>
113
+ header?: React.ReactNode
114
+ footer?: React.ReactNode
115
+ children?: React.ReactNode
94
116
  }
95
117
 
96
- function AuthSwitch({
97
- LoginComponent = Login,
98
- CallbackComponent = LoginCallback,
99
- children,
100
- ...props
101
- }: AuthSwitchProps) {
118
+ function AuthSwitch({CallbackComponent = LoginCallback, children, ...props}: AuthSwitchProps) {
102
119
  const authState = useAuthState()
103
120
 
121
+ const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession
122
+ const loginUrl = useLoginUrl()
123
+
124
+ useEffect(() => {
125
+ if (isLoggedOut) {
126
+ window.location.href = loginUrl
127
+ }
128
+ }, [isLoggedOut, loginUrl])
129
+
104
130
  switch (authState.type) {
105
131
  case AuthStateType.ERROR: {
106
132
  throw new AuthError(authState.error)
@@ -111,8 +137,12 @@ function AuthSwitch({
111
137
  case AuthStateType.LOGGED_IN: {
112
138
  return children
113
139
  }
140
+ case AuthStateType.LOGGED_OUT: {
141
+ return null
142
+ }
114
143
  default: {
115
- return <LoginComponent {...props} />
144
+ // @ts-expect-error - This state should never happen
145
+ throw new Error(`Invalid auth state: ${authState.type}`)
116
146
  }
117
147
  }
118
148
  }
@@ -1,7 +1,7 @@
1
- import {screen, waitFor} from '@testing-library/react'
1
+ import {render, waitFor} from '@testing-library/react'
2
2
  import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest'
3
3
 
4
- import {renderWithWrappers} from './authTestHelpers'
4
+ import {ResourceProvider} from '../../context/ResourceProvider'
5
5
 
6
6
  // Mock `useHandleAuthCallback`
7
7
  vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
@@ -37,8 +37,13 @@ describe('LoginCallback', () => {
37
37
 
38
38
  it('renders a loading message', async () => {
39
39
  const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
40
- renderWithWrappers(<LoginCallback />)
41
- expect(screen.getByText('Logging you in…')).toBeInTheDocument()
40
+ const {container} = render(
41
+ <ResourceProvider fallback={null}>
42
+ <LoginCallback />
43
+ </ResourceProvider>,
44
+ )
45
+ // The callback screen renders null check that it renders nothing
46
+ expect(container.innerHTML).toBe('')
42
47
  })
43
48
 
44
49
  it('handles a successful callback and calls history.replaceState', async () => {
@@ -46,7 +51,11 @@ describe('LoginCallback', () => {
46
51
  vi.stubGlobal('location', {href: 'http://localhost#sid=valid'})
47
52
  const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
48
53
 
49
- renderWithWrappers(<LoginCallback />)
54
+ render(
55
+ <ResourceProvider fallback={null}>
56
+ <LoginCallback />
57
+ </ResourceProvider>,
58
+ )
50
59
 
51
60
  await waitFor(() => {
52
61
  expect(history.replaceState).toHaveBeenCalledWith(
@@ -62,7 +71,11 @@ describe('LoginCallback', () => {
62
71
  vi.stubGlobal('location', {href: 'http://localhost#sid=invalid'})
63
72
  const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
64
73
 
65
- renderWithWrappers(<LoginCallback />)
74
+ render(
75
+ <ResourceProvider fallback={null}>
76
+ <LoginCallback />
77
+ </ResourceProvider>,
78
+ )
66
79
 
67
80
  await waitFor(() => {
68
81
  expect(history.replaceState).not.toHaveBeenCalled()
@@ -1,9 +1,7 @@
1
1
  import {useEffect} from 'react'
2
2
 
3
3
  import {useHandleAuthCallback} from '../../hooks/auth/useHandleAuthCallback'
4
- import {LoginLayout, type LoginLayoutProps} from './LoginLayout'
5
4
 
6
- /**
7
5
  /**
8
6
  * Component shown during auth callback processing that handles login completion.
9
7
  * Automatically processes the auth callback when mounted and updates the URL
@@ -11,7 +9,7 @@ import {LoginLayout, type LoginLayoutProps} from './LoginLayout'
11
9
  *
12
10
  * @alpha
13
11
  */
14
- export function LoginCallback({header, footer}: LoginLayoutProps): React.ReactNode {
12
+ export function LoginCallback(): React.ReactNode {
15
13
  const handleAuthCallback = useHandleAuthCallback()
16
14
 
17
15
  useEffect(() => {
@@ -25,12 +23,5 @@ export function LoginCallback({header, footer}: LoginLayoutProps): React.ReactNo
25
23
  })
26
24
  }, [handleAuthCallback])
27
25
 
28
- return (
29
- <LoginLayout header={header} footer={footer}>
30
- <div className="sc-login-callback">
31
- <h1 className="sc-login-callback__title">Logging you in…</h1>
32
- <div className="sc-login-callback__loading">Loading…</div>
33
- </div>
34
- </LoginLayout>
35
- )
26
+ return null
36
27
  }
@@ -1,8 +1,8 @@
1
- import {fireEvent, screen, waitFor} from '@testing-library/react'
1
+ import {fireEvent, render, screen, waitFor} from '@testing-library/react'
2
2
  import {describe, expect, it, vi} from 'vitest'
3
3
 
4
+ import {ResourceProvider} from '../../context/ResourceProvider'
4
5
  import {AuthError} from './AuthError'
5
- import {renderWithWrappers} from './authTestHelpers'
6
6
  import {LoginError} from './LoginError'
7
7
 
8
8
  vi.mock('../../hooks/auth/useLogOut', () => ({
@@ -14,7 +14,11 @@ describe('LoginError', () => {
14
14
  const mockReset = vi.fn()
15
15
  const error = new AuthError(new Error('Test error'))
16
16
 
17
- renderWithWrappers(<LoginError error={error} resetErrorBoundary={mockReset} />)
17
+ render(
18
+ <ResourceProvider fallback={null}>
19
+ <LoginError error={error} resetErrorBoundary={mockReset} />
20
+ </ResourceProvider>,
21
+ )
18
22
 
19
23
  expect(screen.getByText('Authentication Error')).toBeInTheDocument()
20
24
  const retryButton = screen.getByRole('button', {name: 'Retry'})
@@ -33,7 +37,11 @@ describe('LoginError', () => {
33
37
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
34
38
 
35
39
  expect(() => {
36
- renderWithWrappers(<LoginError error={nonAuthError} resetErrorBoundary={mockReset} />)
40
+ render(
41
+ <ResourceProvider fallback={null}>
42
+ <LoginError error={nonAuthError} resetErrorBoundary={mockReset} />
43
+ </ResourceProvider>,
44
+ )
37
45
  }).toThrow('Non-auth error')
38
46
 
39
47
  consoleErrorSpy.mockRestore() // Restore original console.error behavior
@@ -3,12 +3,11 @@ import {type FallbackProps} from 'react-error-boundary'
3
3
 
4
4
  import {useLogOut} from '../../hooks/auth/useLogOut'
5
5
  import {AuthError} from './AuthError'
6
- import {LoginLayout, type LoginLayoutProps} from './LoginLayout'
7
6
 
8
7
  /**
9
8
  * @alpha
10
9
  */
11
- export type LoginErrorProps = FallbackProps & LoginLayoutProps
10
+ export type LoginErrorProps = FallbackProps
12
11
 
13
12
  /**
14
13
  * Displays authentication error details and provides retry functionality.
@@ -16,12 +15,7 @@ export type LoginErrorProps = FallbackProps & LoginLayoutProps
16
15
  *
17
16
  * @alpha
18
17
  */
19
- export function LoginError({
20
- error,
21
- resetErrorBoundary,
22
- header,
23
- footer,
24
- }: LoginErrorProps): React.ReactNode {
18
+ export function LoginError({error, resetErrorBoundary}: LoginErrorProps): React.ReactNode {
25
19
  if (!(error instanceof AuthError)) throw error
26
20
  const logout = useLogOut()
27
21
 
@@ -31,19 +25,17 @@ export function LoginError({
31
25
  }, [logout, resetErrorBoundary])
32
26
 
33
27
  return (
34
- <LoginLayout header={header} footer={footer}>
35
- <div className="sc-login-error">
36
- <div className="sc-login-error__content">
37
- <h2 className="sc-login-error__title">Authentication Error</h2>
38
- <p className="sc-login-error__description">
39
- Please try again or contact support if the problem persists.
40
- </p>
41
- </div>
42
-
43
- <button className="sc-login-error__button" onClick={handleRetry}>
44
- Retry
45
- </button>
28
+ <div className="sc-login-error">
29
+ <div className="sc-login-error__content">
30
+ <h2 className="sc-login-error__title">Authentication Error</h2>
31
+ <p className="sc-login-error__description">
32
+ Please try again or contact support if the problem persists.
33
+ </p>
46
34
  </div>
47
- </LoginLayout>
35
+
36
+ <button className="sc-login-error__button" onClick={handleRetry}>
37
+ Retry
38
+ </button>
39
+ </div>
48
40
  )
49
41
  }
@@ -1,12 +1,16 @@
1
- import {screen} from '@testing-library/react'
1
+ import {render, screen} from '@testing-library/react'
2
2
  import {describe, expect, it} from 'vitest'
3
3
 
4
- import {renderWithWrappers} from './authTestHelpers'
4
+ import {ResourceProvider} from '../../context/ResourceProvider'
5
5
  import {LoginFooter} from './LoginFooter'
6
6
 
7
7
  describe('LoginFooter', () => {
8
8
  it('renders footer links', () => {
9
- renderWithWrappers(<LoginFooter />)
9
+ render(
10
+ <ResourceProvider fallback={null}>
11
+ <LoginFooter />
12
+ </ResourceProvider>,
13
+ )
10
14
  expect(screen.getByText('Community')).toBeInTheDocument()
11
15
  expect(screen.getByText('Docs')).toBeInTheDocument()
12
16
  expect(screen.getByText('Privacy')).toBeInTheDocument()
@@ -0,0 +1,157 @@
1
+ import {type SanityConfig, type SanityInstance} from '@sanity/sdk'
2
+ import {render, screen} from '@testing-library/react'
3
+ import {StrictMode, use, useEffect} from 'react'
4
+ import {describe, expect, it} from 'vitest'
5
+
6
+ import {ResourceProvider} from './ResourceProvider'
7
+ import {SanityInstanceContext} from './SanityInstanceContext'
8
+
9
+ const testConfig: SanityConfig = {
10
+ projectId: 'test-project',
11
+ dataset: 'test-dataset',
12
+ }
13
+
14
+ function promiseWithResolvers<T = void>(): {
15
+ promise: Promise<T>
16
+ resolve: (t: T) => void
17
+ reject: (error: unknown) => void
18
+ } {
19
+ let resolve!: (t: T) => void
20
+ let reject!: (error: unknown) => void
21
+ const promise = new Promise<T>((res, rej) => {
22
+ resolve = res
23
+ reject = rej
24
+ })
25
+ return {resolve, reject, promise}
26
+ }
27
+
28
+ describe('ResourceProvider', () => {
29
+ it('renders children when loaded', () => {
30
+ render(
31
+ <ResourceProvider {...testConfig} fallback={<div>Loading...</div>}>
32
+ <div data-testid="test-child">Child Component</div>
33
+ </ResourceProvider>,
34
+ )
35
+
36
+ expect(screen.getByTestId('test-child')).toBeInTheDocument()
37
+ })
38
+
39
+ it('shows fallback during loading', () => {
40
+ const {promise, resolve} = promiseWithResolvers()
41
+ function SuspendingChild(): React.ReactNode {
42
+ throw promise
43
+ }
44
+
45
+ render(
46
+ <ResourceProvider {...testConfig} fallback={<div data-testid="fallback">Loading...</div>}>
47
+ <SuspendingChild />
48
+ </ResourceProvider>,
49
+ )
50
+
51
+ expect(screen.getByTestId('fallback')).toBeInTheDocument()
52
+ resolve()
53
+ })
54
+
55
+ it('creates root instance when no parent context exists', async () => {
56
+ const {promise, resolve} = promiseWithResolvers<SanityInstance | null>()
57
+
58
+ const CaptureInstance = () => {
59
+ const instance = use(SanityInstanceContext)
60
+ useEffect(() => resolve(instance), [instance])
61
+ return null
62
+ }
63
+
64
+ render(
65
+ <ResourceProvider {...testConfig} fallback={null}>
66
+ <CaptureInstance />
67
+ </ResourceProvider>,
68
+ )
69
+
70
+ await expect(promise).resolves.toMatchObject({
71
+ config: testConfig,
72
+ isDisposed: expect.any(Function),
73
+ })
74
+ })
75
+
76
+ it('creates child instance when parent context exists', async () => {
77
+ const parentConfig: SanityConfig = {...testConfig, dataset: 'parent-dataset'}
78
+ const child = promiseWithResolvers<SanityInstance | null>()
79
+
80
+ const CaptureInstance = () => {
81
+ const childInstance = use(SanityInstanceContext)
82
+ useEffect(() => child.resolve(childInstance), [childInstance])
83
+ return null
84
+ }
85
+
86
+ render(
87
+ <ResourceProvider {...parentConfig} fallback={null}>
88
+ <ResourceProvider {...testConfig} fallback={null}>
89
+ <CaptureInstance />
90
+ </ResourceProvider>
91
+ </ResourceProvider>,
92
+ )
93
+
94
+ const childInstance = await child.promise
95
+ expect(childInstance?.config).toEqual(testConfig)
96
+ expect(childInstance?.isDisposed()).toBe(false)
97
+ })
98
+
99
+ it('disposes instance when unmounted', async () => {
100
+ const {promise, resolve} = promiseWithResolvers<SanityInstance | null>()
101
+ const CaptureInstance = () => {
102
+ const instance = use(SanityInstanceContext)
103
+ useEffect(() => resolve(instance), [instance])
104
+ return null
105
+ }
106
+
107
+ const {unmount} = render(
108
+ <ResourceProvider {...testConfig} fallback={null}>
109
+ <CaptureInstance />
110
+ </ResourceProvider>,
111
+ )
112
+
113
+ unmount()
114
+ await new Promise((r) => setTimeout(r, 0))
115
+ const instance = await promise
116
+
117
+ expect(instance?.isDisposed()).toBe(true)
118
+ })
119
+
120
+ it('does not dispose on quick remount (Strict Mode)', async () => {
121
+ const {promise, resolve} = promiseWithResolvers<SanityInstance | null>()
122
+ const CaptureInstance = () => {
123
+ const instance = use(SanityInstanceContext)
124
+ useEffect(() => resolve(instance), [instance])
125
+ return null
126
+ }
127
+
128
+ render(
129
+ <StrictMode>
130
+ <ResourceProvider {...testConfig} fallback={null}>
131
+ <CaptureInstance />
132
+ </ResourceProvider>
133
+ </StrictMode>,
134
+ )
135
+
136
+ const instance = await promise
137
+
138
+ expect(instance?.isDisposed()).toBe(false)
139
+ })
140
+
141
+ it('uses default fallback when none provided', () => {
142
+ const {promise, resolve} = promiseWithResolvers()
143
+ function SuspendingChild(): React.ReactNode {
144
+ throw promise
145
+ }
146
+
147
+ render(
148
+ // @ts-expect-error Testing fallback behavior
149
+ <ResourceProvider {...testConfig}>
150
+ <SuspendingChild />
151
+ </ResourceProvider>,
152
+ )
153
+
154
+ expect(screen.getByText(/Warning: No fallback provided/)).toBeInTheDocument()
155
+ resolve()
156
+ })
157
+ })