From 5c971117d6fbc938082ee26b892827c2a9a7c777 Mon Sep 17 00:00:00 2001 From: Magnus Holm Date: Mon, 22 Sep 2025 13:55:44 +0200 Subject: [PATCH] feat: require project ID for project-level fetchers --- packages/core/src/datasets/datasets.test.ts | 2 +- packages/core/src/datasets/datasets.ts | 31 ++++--- packages/core/src/project/project.ts | 26 ++---- .../src/hooks/datasets/useDatasets.test.ts | 80 ------------------- .../react/src/hooks/datasets/useDatasets.ts | 31 +++---- .../react/src/hooks/helpers/useStoreState.tsx | 17 ++++ .../src/hooks/projects/useProject.test.ts | 80 ------------------- .../react/src/hooks/projects/useProject.ts | 32 +++----- 8 files changed, 62 insertions(+), 237 deletions(-) delete mode 100644 packages/react/src/hooks/datasets/useDatasets.test.ts create mode 100644 packages/react/src/hooks/helpers/useStoreState.tsx delete mode 100644 packages/react/src/hooks/projects/useProject.test.ts diff --git a/packages/core/src/datasets/datasets.test.ts b/packages/core/src/datasets/datasets.test.ts index 1ff10ac3d..b4800209c 100644 --- a/packages/core/src/datasets/datasets.test.ts +++ b/packages/core/src/datasets/datasets.test.ts @@ -25,7 +25,7 @@ describe('datasets', () => { observable: of(mockClient), } as StateSource) - const result = await resolveDatasets(instance) + const result = await resolveDatasets(instance, {projectId: 'p'}) expect(result).toEqual(datasets) expect(list).toHaveBeenCalled() }) diff --git a/packages/core/src/datasets/datasets.ts b/packages/core/src/datasets/datasets.ts index f4048569d..6bbf0974b 100644 --- a/packages/core/src/datasets/datasets.ts +++ b/packages/core/src/datasets/datasets.ts @@ -1,29 +1,28 @@ import {switchMap} from 'rxjs' import {getClientState} from '../client/clientStore' -import {type ProjectHandle} from '../config/sanityConfig' import {createFetcherStore} from '../utils/createFetcherStore' const API_VERSION = 'v2025-02-19' +type DatasetOptions = { + projectId: string +} + /** @public */ export const datasets = createFetcherStore({ name: 'Datasets', - getKey: (instance, options?: ProjectHandle) => { - const projectId = options?.projectId ?? instance.config.projectId - if (!projectId) { - throw new Error('A projectId is required to use the project API.') - } - return projectId - }, - fetcher: (instance) => (options?: ProjectHandle) => { - return getClientState(instance, { - apiVersion: API_VERSION, - // non-null assertion is fine because we check above - projectId: (options?.projectId ?? instance.config.projectId)!, - useProjectHostname: true, - }).observable.pipe(switchMap((client) => client.observable.datasets.list())) - }, + getKey: (_, {projectId}: DatasetOptions) => projectId, + fetcher: + (instance) => + ({projectId}: DatasetOptions) => { + return getClientState(instance, { + apiVersion: API_VERSION, + // non-null assertion is fine because we check above + projectId, + useProjectHostname: true, + }).observable.pipe(switchMap((client) => client.observable.datasets.list())) + }, }) /** @public */ diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts index a553794ca..e0d6da423 100644 --- a/packages/core/src/project/project.ts +++ b/packages/core/src/project/project.ts @@ -1,37 +1,25 @@ import {switchMap} from 'rxjs' import {getClientState} from '../client/clientStore' -import {type ProjectHandle} from '../config/sanityConfig' import {createFetcherStore} from '../utils/createFetcherStore' const API_VERSION = 'v2025-02-19' +type ProjectOptions = { + projectId: string +} + const project = createFetcherStore({ name: 'Project', - getKey: (instance, options?: ProjectHandle) => { - const projectId = options?.projectId ?? instance.config.projectId - if (!projectId) { - throw new Error('A projectId is required to use the project API.') - } - return projectId - }, + getKey: (_, {projectId}: ProjectOptions) => projectId, fetcher: (instance) => - (options: ProjectHandle = {}) => { - const projectId = options.projectId ?? instance.config.projectId - + ({projectId}: ProjectOptions) => { return getClientState(instance, { apiVersion: API_VERSION, scope: 'global', projectId, - }).observable.pipe( - switchMap((client) => - client.observable.projects.getById( - // non-null assertion is fine with the above throwing - (projectId ?? instance.config.projectId)!, - ), - ), - ) + }).observable.pipe(switchMap((client) => client.observable.projects.getById(projectId))) }, }) diff --git a/packages/react/src/hooks/datasets/useDatasets.test.ts b/packages/react/src/hooks/datasets/useDatasets.test.ts deleted file mode 100644 index 5bcff5e4f..000000000 --- a/packages/react/src/hooks/datasets/useDatasets.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {getDatasetsState, type ProjectHandle, type SanityInstance} from '@sanity/sdk' -import {beforeEach, describe, expect, it, vi} from 'vitest' - -import {createStateSourceHook} from '../helpers/createStateSourceHook' - -// Mock dependencies -vi.mock('@sanity/sdk', () => ({ - getDatasetsState: vi.fn(() => ({ - getCurrent: vi.fn(() => undefined), // Mocking getCurrent to satisfy the call within shouldSuspend - })), - resolveDatasets: vi.fn(), -})) -vi.mock('../helpers/createStateSourceHook', () => ({ - createStateSourceHook: vi.fn(), -})) - -describe('useDatasets', () => { - // Use beforeEach to reset modules and ensure mocks are fresh for each test - beforeEach(() => { - vi.resetModules() - // Re-mock dependencies for each test after resetModules - vi.mock('@sanity/sdk', () => ({ - getDatasetsState: vi.fn(() => ({ - getCurrent: vi.fn(() => undefined), - })), - resolveDatasets: vi.fn(), - })) - vi.mock('../helpers/createStateSourceHook', () => ({ - createStateSourceHook: vi.fn(), - })) - }) - - it('should call createStateSourceHook with correct arguments on import', async () => { - // Dynamically import the hook *after* mocks are set up and modules reset - await import('./useDatasets') - - // Check if createStateSourceHook was called during the module evaluation (import) - expect(createStateSourceHook).toHaveBeenCalled() - expect(createStateSourceHook).toHaveBeenCalledWith( - expect.objectContaining({ - getState: expect.any(Function), - shouldSuspend: expect.any(Function), - suspender: expect.any(Function), // Actual function reference doesn't matter here as it's mocked - getConfig: expect.any(Function), // Actual function reference doesn't matter here - }), - ) - }) - - it('shouldSuspend should call getDatasetsState and getCurrent', async () => { - // Dynamically import the hook *after* mocks are set up and modules reset - await import('./useDatasets') - - // Get the arguments passed to createStateSourceHook - // Need to ensure createStateSourceHook mock is correctly typed for access - const mockCreateStateSourceHook = createStateSourceHook as ReturnType - expect(mockCreateStateSourceHook.mock.calls.length).toBeGreaterThan(0) - const createStateSourceHookArgs = mockCreateStateSourceHook.mock.calls[0][0] - const shouldSuspend = createStateSourceHookArgs.shouldSuspend - - // Mock instance and projectHandle for the test call - const mockInstance = {} as SanityInstance // Use specific type - const mockProjectHandle = {} as ProjectHandle // Use specific type - - // Call the shouldSuspend function - const result = shouldSuspend(mockInstance, mockProjectHandle) - - // Assert that getDatasetsState was called with the correct arguments - // Need to ensure getDatasetsState mock is correctly typed for access - const mockGetDatasetsState = getDatasetsState as ReturnType - expect(mockGetDatasetsState).toHaveBeenCalledWith(mockInstance, mockProjectHandle) - - // Assert that getCurrent was called on the result of getDatasetsState - expect(mockGetDatasetsState.mock.results.length).toBeGreaterThan(0) - const getDatasetsStateMockResult = mockGetDatasetsState.mock.results[0].value - expect(getDatasetsStateMockResult.getCurrent).toHaveBeenCalled() - - // Assert the result of shouldSuspend based on the mocked getCurrent value - expect(result).toBe(true) // Since getCurrent is mocked to return undefined - }) -}) diff --git a/packages/react/src/hooks/datasets/useDatasets.ts b/packages/react/src/hooks/datasets/useDatasets.ts index 0587eb6fd..e45001940 100644 --- a/packages/react/src/hooks/datasets/useDatasets.ts +++ b/packages/react/src/hooks/datasets/useDatasets.ts @@ -1,14 +1,9 @@ import {type DatasetsResponse} from '@sanity/client' -import { - getDatasetsState, - type ProjectHandle, - resolveDatasets, - type SanityInstance, - type StateSource, -} from '@sanity/sdk' -import {identity} from 'rxjs' +import {getDatasetsState} from '@sanity/sdk' +import {useMemo} from 'react' -import {createStateSourceHook} from '../helpers/createStateSourceHook' +import {useSanityInstance} from '../context/useSanityInstance' +import {useStoreState} from '../helpers/useStoreState' type UseDatasets = { /** @@ -39,14 +34,10 @@ type UseDatasets = { * @public * @function */ -export const useDatasets: UseDatasets = createStateSourceHook({ - getState: getDatasetsState as ( - instance: SanityInstance, - projectHandle?: ProjectHandle, - ) => StateSource, - shouldSuspend: (instance, projectHandle?: ProjectHandle) => - // remove `undefined` since we're suspending when that is the case - getDatasetsState(instance, projectHandle).getCurrent() === undefined, - suspender: resolveDatasets, - getConfig: identity as (projectHandle?: ProjectHandle) => ProjectHandle, -}) +export const useDatasets: UseDatasets = () => { + const instance = useSanityInstance() + const {projectId} = instance.config + if (!projectId) throw new Error('useDatasets must be configured with projectId') + const state = useMemo(() => getDatasetsState(instance, {projectId}), [instance, projectId]) + return useStoreState(state) +} diff --git a/packages/react/src/hooks/helpers/useStoreState.tsx b/packages/react/src/hooks/helpers/useStoreState.tsx new file mode 100644 index 000000000..aca6a6f50 --- /dev/null +++ b/packages/react/src/hooks/helpers/useStoreState.tsx @@ -0,0 +1,17 @@ +import {type StateSource} from '@sanity/sdk' +import {useSyncExternalStore} from 'react' +import {first, firstValueFrom} from 'rxjs' + +/** + * useStoreState is a hook around a StateSource which is initially undefined and then + * eventually always defined. + */ +export function useStoreState(state: StateSource): T { + const current = state.getCurrent() + + if (current === undefined) { + throw firstValueFrom(state.observable.pipe(first((i) => i !== undefined))) + } + + return useSyncExternalStore(state.subscribe, state.getCurrent as () => T) +} diff --git a/packages/react/src/hooks/projects/useProject.test.ts b/packages/react/src/hooks/projects/useProject.test.ts deleted file mode 100644 index 4a900f0da..000000000 --- a/packages/react/src/hooks/projects/useProject.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {getProjectState, type ProjectHandle, type SanityInstance} from '@sanity/sdk' -import {beforeEach, describe, expect, it, vi} from 'vitest' - -import {createStateSourceHook} from '../helpers/createStateSourceHook' - -// Mock dependencies -vi.mock('@sanity/sdk', () => ({ - getProjectState: vi.fn(() => ({ - getCurrent: vi.fn(() => undefined), // Mocking getCurrent to satisfy the call within shouldSuspend - })), - resolveProject: vi.fn(), -})) -vi.mock('../helpers/createStateSourceHook', () => ({ - createStateSourceHook: vi.fn(), -})) - -describe('useProject', () => { - // Use beforeEach to reset modules and ensure mocks are fresh for each test - beforeEach(() => { - vi.resetModules() - // Re-mock dependencies for each test after resetModules - vi.mock('@sanity/sdk', () => ({ - getProjectState: vi.fn(() => ({ - getCurrent: vi.fn(() => undefined), - })), - resolveProject: vi.fn(), - })) - vi.mock('../helpers/createStateSourceHook', () => ({ - createStateSourceHook: vi.fn(), - })) - }) - - it('should call createStateSourceHook with correct arguments on import', async () => { - // Dynamically import the hook *after* mocks are set up and modules reset - await import('./useProject') - - // Check if createStateSourceHook was called during the module evaluation (import) - expect(createStateSourceHook).toHaveBeenCalled() - expect(createStateSourceHook).toHaveBeenCalledWith( - expect.objectContaining({ - getState: expect.any(Function), - shouldSuspend: expect.any(Function), - suspender: expect.any(Function), // Actual function reference doesn't matter here as it's mocked - getConfig: expect.any(Function), // Actual function reference doesn't matter here - }), - ) - }) - - it('shouldSuspend should call getProjectState and getCurrent', async () => { - // Dynamically import the hook *after* mocks are set up and modules reset - await import('./useProject') - - // Get the arguments passed to createStateSourceHook - // Need to ensure createStateSourceHook mock is correctly typed for access - const mockCreateStateSourceHook = createStateSourceHook as ReturnType - expect(mockCreateStateSourceHook.mock.calls.length).toBeGreaterThan(0) - const createStateSourceHookArgs = mockCreateStateSourceHook.mock.calls[0][0] - const shouldSuspend = createStateSourceHookArgs.shouldSuspend - - // Mock instance and projectHandle for the test call - const mockInstance = {} as SanityInstance // Use specific type - const mockProjectHandle = {} as ProjectHandle // Use specific type - - // Call the shouldSuspend function - const result = shouldSuspend(mockInstance, mockProjectHandle) - - // Assert that getProjectState was called with the correct arguments - // Need to ensure getProjectState mock is correctly typed for access - const mockGetProjectState = getProjectState as ReturnType - expect(mockGetProjectState).toHaveBeenCalledWith(mockInstance, mockProjectHandle) - - // Assert that getCurrent was called on the result of getProjectState - expect(mockGetProjectState.mock.results.length).toBeGreaterThan(0) - const getProjectStateMockResult = mockGetProjectState.mock.results[0].value - expect(getProjectStateMockResult.getCurrent).toHaveBeenCalled() - - // Assert the result of shouldSuspend based on the mocked getCurrent value - expect(result).toBe(true) // Since getCurrent is mocked to return undefined - }) -}) diff --git a/packages/react/src/hooks/projects/useProject.ts b/packages/react/src/hooks/projects/useProject.ts index 254eb799e..4b4e65ac5 100644 --- a/packages/react/src/hooks/projects/useProject.ts +++ b/packages/react/src/hooks/projects/useProject.ts @@ -1,14 +1,8 @@ -import { - getProjectState, - type ProjectHandle, - resolveProject, - type SanityInstance, - type SanityProject, - type StateSource, -} from '@sanity/sdk' -import {identity} from 'rxjs' +import {getProjectState, type ProjectHandle, type SanityProject} from '@sanity/sdk' +import {useMemo} from 'react' -import {createStateSourceHook} from '../helpers/createStateSourceHook' +import {useSanityInstance} from '../context/useSanityInstance' +import {useStoreState} from '../helpers/useStoreState' type UseProject = { /** @@ -38,14 +32,10 @@ type UseProject = { * @public * @function */ -export const useProject: UseProject = createStateSourceHook({ - // remove `undefined` since we're suspending when that is the case - getState: getProjectState as ( - instance: SanityInstance, - projectHandle?: ProjectHandle, - ) => StateSource, - shouldSuspend: (instance, projectHandle) => - getProjectState(instance, projectHandle).getCurrent() === undefined, - suspender: resolveProject, - getConfig: identity, -}) +export const useProject: UseProject = (options) => { + const instance = useSanityInstance() + const projectId = options?.projectId ?? instance.config.projectId + if (!projectId) throw new Error('useProject must be configured with projectId') + const state = useMemo(() => getProjectState(instance, {projectId}), [instance, projectId]) + return useStoreState(state) +}