diff --git a/packages/core/src/_exports/index.ts b/packages/core/src/_exports/index.ts index 758df273e..b07210c54 100644 --- a/packages/core/src/_exports/index.ts +++ b/packages/core/src/_exports/index.ts @@ -52,11 +52,13 @@ export { export { type DatasetHandle, type DocumentHandle, + type DocumentSource, type DocumentTypeHandle, type PerspectiveHandle, type ProjectHandle, type ReleasePerspective, type SanityConfig, + sourceFor, } from '../config/sanityConfig' export {getDatasetsState, resolveDatasets} from '../datasets/datasets' export { diff --git a/packages/core/src/config/sanityConfig.ts b/packages/core/src/config/sanityConfig.ts index 7dbd8accd..9cbec0d85 100644 --- a/packages/core/src/config/sanityConfig.ts +++ b/packages/core/src/config/sanityConfig.ts @@ -81,3 +81,32 @@ export interface SanityConfig extends DatasetHandle, PerspectiveHandle { enabled: boolean } } + +/** + * Represents a source which can be used by various functionality. + * + * @see sourceFor For how to initialize a new source for a dataset. + * @public + */ +export type DocumentSource = { + [__sourceData]: { + projectId: string + dataset: string + } +} + +/** + * An internal symbol to avoid users to access data inside here. + * + * @internal + */ +export const __sourceData = Symbol('Sanity.DocumentSource') + +/** + * Creates a new {@link DocumentSource} object based on a projectId and dataset. + * + * @public + */ +export function sourceFor(data: {projectId: string; dataset: string}): DocumentSource { + return {[__sourceData]: data} +} diff --git a/packages/core/src/preview/subscribeToStateAndFetchBatches.ts b/packages/core/src/preview/subscribeToStateAndFetchBatches.ts index 828fce284..252f365be 100644 --- a/packages/core/src/preview/subscribeToStateAndFetchBatches.ts +++ b/packages/core/src/preview/subscribeToStateAndFetchBatches.ts @@ -14,6 +14,7 @@ import { tap, } from 'rxjs' +import {sourceFor} from '../config/sanityConfig' import {getQueryState, resolveQuery} from '../query/queryStore' import {type BoundDatasetKey} from '../store/createActionBinder' import {type StoreContext} from '../store/defineStore' @@ -66,8 +67,7 @@ export const subscribeToStateAndFetchBatches = ({ params, tag: PREVIEW_TAG, perspective: PREVIEW_PERSPECTIVE, - projectId, - dataset, + source: sourceFor({projectId, dataset}), }) const source$ = defer(() => { if (getCurrent() === undefined) { @@ -78,8 +78,7 @@ export const subscribeToStateAndFetchBatches = ({ tag: PREVIEW_TAG, perspective: PREVIEW_PERSPECTIVE, signal: controller.signal, - projectId, - dataset, + source: sourceFor({projectId, dataset}), }), ).pipe(switchMap(() => observable)) } diff --git a/packages/core/src/projection/subscribeToStateAndFetchBatches.ts b/packages/core/src/projection/subscribeToStateAndFetchBatches.ts index 6bc2c102b..b7346cd0e 100644 --- a/packages/core/src/projection/subscribeToStateAndFetchBatches.ts +++ b/packages/core/src/projection/subscribeToStateAndFetchBatches.ts @@ -16,6 +16,7 @@ import { tap, } from 'rxjs' +import {sourceFor} from '../config/sanityConfig' import {getQueryState, resolveQuery} from '../query/queryStore' import {type BoundDatasetKey} from '../store/createActionBinder' import {type StoreContext} from '../store/defineStore' @@ -96,8 +97,7 @@ export const subscribeToStateAndFetchBatches = ({ const {getCurrent, observable} = getQueryState(instance, { query, params, - projectId, - dataset, + source: sourceFor({projectId, dataset}), tag: PROJECTION_TAG, perspective: PROJECTION_PERSPECTIVE, }) @@ -108,8 +108,7 @@ export const subscribeToStateAndFetchBatches = ({ resolveQuery(instance, { query, params, - projectId, - dataset, + source: sourceFor({projectId, dataset}), tag: PROJECTION_TAG, perspective: PROJECTION_PERSPECTIVE, signal: controller.signal, diff --git a/packages/core/src/query/queryStore.test.ts b/packages/core/src/query/queryStore.test.ts index 399f8adc0..b9cd52ce9 100644 --- a/packages/core/src/query/queryStore.test.ts +++ b/packages/core/src/query/queryStore.test.ts @@ -3,6 +3,7 @@ import {delay, filter, firstValueFrom, Observable, of, Subject} from 'rxjs' import {beforeEach, describe, expect, it, vi} from 'vitest' import {getClientState} from '../client/clientStore' +import {sourceFor as getSource} from '../config/sanityConfig' import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance' import {type StateSource} from '../store/createStateSourceAction' import {getQueryState, resolveQuery} from './queryStore' @@ -17,6 +18,7 @@ vi.mock('../client/clientStore', () => ({ })) describe('queryStore', () => { + const source = getSource({projectId: 'test', dataset: 'test'}) let instance: SanityInstance let liveEvents: Subject let fetch: SanityClient['observable']['fetch'] @@ -61,7 +63,7 @@ describe('queryStore', () => { it('initializes query state and cleans up after unsubscribe', async () => { const query = '*[_type == "movie"]' - const state = getQueryState(instance, {query}) + const state = getQueryState(instance, {query, source, perspective: 'drafts'}) // Initially undefined before subscription expect(state.getCurrent()).toBeUndefined() @@ -90,7 +92,7 @@ describe('queryStore', () => { it('maintains state when multiple subscribers exist', async () => { const query = '*[_type == "movie"]' - const state = getQueryState(instance, {query}) + const state = getQueryState(instance, {query, source, perspective: 'drafts'}) // Add two subscribers const unsubscribe1 = state.subscribe() @@ -127,13 +129,13 @@ describe('queryStore', () => { it('resolveQuery works without affecting subscriber cleanup', async () => { const query = '*[_type == "movie"]' - const state = getQueryState(instance, {query}) + const state = getQueryState(instance, {query, source, perspective: 'drafts'}) // Check that getQueryState starts undefined expect(state.getCurrent()).toBeUndefined() // Use resolveQuery which should not add a subscriber - const result = await resolveQuery(instance, {query}) + const result = await resolveQuery(instance, {query, source, perspective: 'drafts'}) expect(result).toEqual([ {_id: 'movie1', _type: 'movie', title: 'Movie 1'}, {_id: 'movie2', _type: 'movie', title: 'Movie 2'}, @@ -160,7 +162,12 @@ describe('queryStore', () => { const abortController = new AbortController() // Create a promise that will reject when aborted - const queryPromise = resolveQuery(instance, {query, signal: abortController.signal}) + const queryPromise = resolveQuery(instance, { + query, + source, + perspective: 'drafts', + signal: abortController.signal, + }) // Abort the request abortController.abort() @@ -169,7 +176,9 @@ describe('queryStore', () => { await expect(queryPromise).rejects.toThrow('The operation was aborted.') // Verify state is cleared after abort - expect(getQueryState(instance, {query}).getCurrent()).toBeUndefined() + expect( + getQueryState(instance, {query, source, perspective: 'drafts'}).getCurrent(), + ).toBeUndefined() }) it('refetches query when receiving live event with matching sync tag', async () => { @@ -188,7 +197,11 @@ describe('queryStore', () => { ) const query = '*[_type == "movie"]' - const state = getQueryState<{_id: string; _type: string; title: string}[]>(instance, {query}) + const state = getQueryState<{_id: string; _type: string; title: string}[]>(instance, { + query, + source, + perspective: 'drafts', + }) const unsubscribe = state.subscribe() await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))) @@ -219,7 +232,7 @@ describe('queryStore', () => { ) const query = '*[_type == "movie"]' - const state = getQueryState(instance, {query}) + const state = getQueryState(instance, {query, source, perspective: 'drafts'}) const unsubscribe = state.subscribe() await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))) @@ -252,7 +265,7 @@ describe('queryStore', () => { ) const query = '*[_type == "movie"]' - const state = getQueryState(instance, {query}) + const state = getQueryState(instance, {query, source, perspective: 'drafts'}) const unsubscribe = state.subscribe() await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))) @@ -289,7 +302,7 @@ describe('queryStore', () => { ) const query = '*[_type == "movie"]' - const state = getQueryState(instance, {query}) + const state = getQueryState(instance, {query, source, perspective: 'drafts'}) const unsubscribe = state.subscribe() // Verify error is thrown when accessing state @@ -300,7 +313,7 @@ describe('queryStore', () => { it('delays query state removal after unsubscribe', async () => { const query = '*[_type == "movie"]' - const state = getQueryState(instance, {query}) + const state = getQueryState(instance, {query, source, perspective: 'drafts'}) const unsubscribe = state.subscribe() await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))) @@ -316,7 +329,7 @@ describe('queryStore', () => { it('preserves query state if a new subscriber subscribes before cleanup delay', async () => { const query = '*[_type == "movie"]' - const state = getQueryState(instance, {query}) + const state = getQueryState(instance, {query, source, perspective: 'drafts'}) const unsubscribe1 = state.subscribe() await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined))) @@ -352,22 +365,16 @@ describe('queryStore', () => { SanityClient['observable']['fetch'] > }) as SanityClient['observable']['fetch']) - - const draftsInstance = createSanityInstance({ - projectId: 'test', - dataset: 'test', + // Same query/options, different implicit perspectives via instance.config + const sDrafts = getQueryState<{_id: string}[]>(instance, { + query: '*[_type == "movie"]', + source, perspective: 'drafts', }) - const publishedInstance = createSanityInstance({ - projectId: 'test', - dataset: 'test', - perspective: 'published', - }) - - // Same query/options, different implicit perspectives via instance.config - const sDrafts = getQueryState<{_id: string}[]>(draftsInstance, {query: '*[_type == "movie"]'}) - const sPublished = getQueryState<{_id: string}[]>(publishedInstance, { + const sPublished = getQueryState<{_id: string}[]>(instance, { query: '*[_type == "movie"]', + source, + perspective: 'published', }) const unsubDrafts = sDrafts.subscribe() @@ -385,9 +392,6 @@ describe('queryStore', () => { unsubDrafts() unsubPublished() - - draftsInstance.dispose() - publishedInstance.dispose() }) it('separates cache entries by explicit perspective in options', async () => { @@ -403,10 +407,12 @@ describe('queryStore', () => { const sDrafts = getQueryState<{_id: string}[]>(base, { query: '*[_type == "movie"]', + source, perspective: 'drafts', }) const sPublished = getQueryState<{_id: string}[]>(base, { query: '*[_type == "movie"]', + source, perspective: 'published', }) diff --git a/packages/core/src/query/queryStore.ts b/packages/core/src/query/queryStore.ts index f563d1179..9d62f8687 100644 --- a/packages/core/src/query/queryStore.ts +++ b/packages/core/src/query/queryStore.ts @@ -1,4 +1,4 @@ -import {CorsOriginError, type ResponseQueryOptions} from '@sanity/client' +import {type ClientPerspective, CorsOriginError, type ResponseQueryOptions} from '@sanity/client' import {type SanityQueryResult} from 'groq' import { catchError, @@ -23,7 +23,7 @@ import { } from 'rxjs' import {getClientState} from '../client/clientStore' -import {type DatasetHandle} from '../config/sanityConfig' +import {type DocumentSource, type ReleasePerspective} from '../config/sanityConfig' import {getPerspectiveState} from '../releases/getPerspectiveState' import {bindActionByDataset, type BoundDatasetKey} from '../store/createActionBinder' import {type SanityInstance} from '../store/createSanityInstance' @@ -35,11 +35,7 @@ import { import {type StoreState} from '../store/createStoreState' import {defineStore, type StoreContext} from '../store/defineStore' import {insecureRandomId} from '../utils/ids' -import { - QUERY_STATE_CLEAR_DELAY, - QUERY_STORE_API_VERSION, - QUERY_STORE_DEFAULT_PERSPECTIVE, -} from './queryStoreConstants' +import {QUERY_STATE_CLEAR_DELAY, QUERY_STORE_API_VERSION} from './queryStoreConstants' import { addSubscriber, cancelQuery, @@ -54,55 +50,28 @@ import { /** * @beta */ -export interface QueryOptions< - TQuery extends string = string, - TDataset extends string = string, - TProjectId extends string = string, -> extends Pick, - DatasetHandle { +export interface QueryOptions + extends Pick { query: TQuery params?: Record + source: DocumentSource + perspective: ClientPerspective | ReleasePerspective } /** * @beta */ -export interface ResolveQueryOptions< - TQuery extends string = string, - TDataset extends string = string, - TProjectId extends string = string, -> extends QueryOptions { +export interface ResolveQueryOptions extends QueryOptions { signal?: AbortSignal } const EMPTY_ARRAY: never[] = [] /** @beta */ -export const getQueryKey = (options: QueryOptions): string => JSON.stringify(options) +export const getQueryKey = (options: Omit): string => + JSON.stringify(options) /** @beta */ -export const parseQueryKey = (key: string): QueryOptions => JSON.parse(key) - -/** - * Ensures the query key includes an effective perspective so that - * implicit differences (e.g. different instance.config.perspective) - * don't collide in the dataset-scoped store. - * - * Since perspectives are unique, we can depend on the release stacks - * to be correct when we retrieve the results. - * - */ -function normalizeOptionsWithPerspective( - instance: SanityInstance, - options: QueryOptions, -): QueryOptions { - if (options.perspective !== undefined) return options - const instancePerspective = instance.config.perspective - return { - ...options, - perspective: - instancePerspective !== undefined ? instancePerspective : QUERY_STORE_DEFAULT_PERSPECTIVE, - } -} +export const parseQueryKey = (key: string): Omit => JSON.parse(key) const queryStore = defineStore({ name: 'QueryStore', @@ -276,7 +245,7 @@ export function getQueryState< TProjectId extends string = string, >( instance: SanityInstance, - queryOptions: QueryOptions, + queryOptions: QueryOptions, ): StateSource | undefined> /** @beta */ @@ -300,16 +269,16 @@ export function getQueryState( const _getQueryState = bindActionByDataset( queryStore, createStateSourceAction({ - selector: ({state, instance}: SelectorContext, options: QueryOptions) => { + selector: ({state}: SelectorContext, options: QueryOptions) => { if (state.error) throw state.error - const key = getQueryKey(normalizeOptionsWithPerspective(instance, options)) + const key = getQueryKey(options) const queryState = state.queries[key] if (queryState?.error) throw queryState.error return queryState?.result }, - onSubscribe: ({state, instance}, options: QueryOptions) => { + onSubscribe: ({state}, options: QueryOptions) => { const subscriptionId = insecureRandomId() - const key = getQueryKey(normalizeOptionsWithPerspective(instance, options)) + const key = getQueryKey(options) state.set('addSubscriber', addSubscriber(key, subscriptionId)) @@ -334,7 +303,7 @@ const _getQueryState = bindActionByDataset( */ export function getQueryErrorState( instance: SanityInstance, - options: {projectId?: string; dataset?: string} = {}, + options: {source: DocumentSource}, ): StateSource { return _getQueryErrorState(instance, options) } @@ -370,7 +339,7 @@ export function resolveQuery< TProjectId extends string = string, >( instance: SanityInstance, - queryOptions: ResolveQueryOptions, + queryOptions: ResolveQueryOptions, ): Promise> /** @beta */ @@ -385,9 +354,8 @@ export function resolveQuery(...args: Parameters): Promise const _resolveQuery = bindActionByDataset( queryStore, ({state, instance}, {signal, ...options}: ResolveQueryOptions) => { - const normalized = normalizeOptionsWithPerspective(instance, options) - const {getCurrent} = getQueryState(instance, normalized) - const key = getQueryKey(normalized) + const {getCurrent} = getQueryState(instance, options) + const key = getQueryKey(options) const aborted$ = signal ? new Observable((observer) => { @@ -428,10 +396,7 @@ const _resolveQuery = bindActionByDataset( * Clears the top-level query store error. * @beta */ -export function clearQueryError( - instance: SanityInstance, - options: {projectId?: string; dataset?: string} = {}, -): void { +export function clearQueryError(instance: SanityInstance, options: {source: DocumentSource}): void { return _clearQueryError(instance, options) } diff --git a/packages/core/src/query/queryStoreConstants.ts b/packages/core/src/query/queryStoreConstants.ts index f2956d885..e22bcf05c 100644 --- a/packages/core/src/query/queryStoreConstants.ts +++ b/packages/core/src/query/queryStoreConstants.ts @@ -7,4 +7,3 @@ */ export const QUERY_STATE_CLEAR_DELAY = 1000 export const QUERY_STORE_API_VERSION = 'v2025-05-06' -export const QUERY_STORE_DEFAULT_PERSPECTIVE = 'drafts' diff --git a/packages/core/src/store/createActionBinder.ts b/packages/core/src/store/createActionBinder.ts index 3648c3712..db64910a5 100644 --- a/packages/core/src/store/createActionBinder.ts +++ b/packages/core/src/store/createActionBinder.ts @@ -1,3 +1,4 @@ +import {__sourceData, type DocumentSource} from '../config/sanityConfig' import {type SanityInstance} from './createSanityInstance' import {createStoreInstance, type StoreInstance} from './createStoreInstance' import {type StoreState} from './createStoreState' @@ -138,10 +139,11 @@ export function createActionBinder< */ export const bindActionByDataset = createActionBinder< BoundDatasetKey, - [object & {projectId?: string; dataset?: string}] + [object & {projectId?: string; dataset?: string; source?: DocumentSource}] >((instance, options) => { - const projectId = options.projectId ?? instance.config.projectId - const dataset = options.dataset ?? instance.config.dataset + const sourceData = options.source?.[__sourceData] + const projectId = sourceData?.projectId ?? options.projectId ?? instance.config.projectId + const dataset = sourceData?.dataset ?? options.dataset ?? instance.config.dataset if (!projectId || !dataset) { throw new Error('This API requires a project ID and dataset configured.') } diff --git a/packages/react/src/hooks/context/useSanityInstance.ts b/packages/react/src/hooks/context/useSanityInstance.ts index 2abb631fb..ae9b91182 100644 --- a/packages/react/src/hooks/context/useSanityInstance.ts +++ b/packages/react/src/hooks/context/useSanityInstance.ts @@ -1,7 +1,8 @@ -import {type SanityConfig, type SanityInstance} from '@sanity/sdk' +import {type DocumentSource, type SanityConfig, type SanityInstance, sourceFor} from '@sanity/sdk' import {useContext} from 'react' import {SanityInstanceContext} from '../../context/SanityInstanceContext' +import {type SourceOptions} from '../../type' /** * Retrieves the current Sanity instance or finds a matching instance from the hierarchy @@ -78,3 +79,36 @@ Please ensure there is a ResourceProvider component with a matching configuratio return match } + +export const useSanityInstanceAndSource = ( + config: SourceOptions, +): [SanityInstance, DocumentSource] => { + const instance = useContext(SanityInstanceContext) + + if (!instance) { + throw new Error( + `SanityInstance context not found. ${config ? `Requested config: ${JSON.stringify(config, null, 2)}. ` : ''}Please ensure that your component is wrapped in a ResourceProvider or a SanityApp component.`, + ) + } + + if (config.source) { + return [instance, config.source] + } + + const match = instance.match(config) + if (match) { + const {projectId, dataset} = match.config + if (!projectId || !dataset) { + throw new Error(`Current SanityInstance is missing projectId and dataset`) + } + return [instance, sourceFor({projectId, dataset})] + } + + const {projectId, dataset} = config + + if (projectId && dataset) { + return [instance, sourceFor({projectId, dataset})] + } + + throw new Error(`Current SanityInstance is missing projectId and dataset`) +} diff --git a/packages/react/src/hooks/documents/useDocuments.ts b/packages/react/src/hooks/documents/useDocuments.ts index e5c3c532e..0e004c22f 100644 --- a/packages/react/src/hooks/documents/useDocuments.ts +++ b/packages/react/src/hooks/documents/useDocuments.ts @@ -1,9 +1,4 @@ -import { - createGroqSearchFilter, - type DatasetHandle, - type DocumentHandle, - type QueryOptions, -} from '@sanity/sdk' +import {createGroqSearchFilter, type DatasetHandle, type DocumentHandle} from '@sanity/sdk' import {type SortOrderingItem} from '@sanity/types' import {pick} from 'lodash-es' import {useCallback, useEffect, useMemo, useState} from 'react' @@ -23,8 +18,7 @@ export interface DocumentsOptions< TDocumentType extends string = string, TDataset extends string = string, TProjectId extends string = string, -> extends DatasetHandle, - Pick { +> extends DatasetHandle { /** * Filter documents by their `_type`. Can be a single type or an array of types. */ @@ -45,6 +39,8 @@ export interface DocumentsOptions< * Text search query to filter results */ search?: string + + params?: Record } /** diff --git a/packages/react/src/hooks/errors/useCorsOriginError.ts b/packages/react/src/hooks/errors/useCorsOriginError.ts index 93397a99d..846c1b745 100644 --- a/packages/react/src/hooks/errors/useCorsOriginError.ts +++ b/packages/react/src/hooks/errors/useCorsOriginError.ts @@ -2,17 +2,20 @@ import {CorsOriginError} from '@sanity/client' import {clearQueryError, getCorsErrorProjectId, getQueryErrorState} from '@sanity/sdk' import {useCallback, useMemo, useSyncExternalStore} from 'react' -import {useSanityInstance} from '../context/useSanityInstance' +import {useSanityInstanceAndSource} from '../context/useSanityInstance' export function useCorsOriginError(): { error: Error | null projectId: string | null clear: () => void } { - const instance = useSanityInstance() - const {getCurrent, subscribe} = useMemo(() => getQueryErrorState(instance), [instance]) + const [instance, source] = useSanityInstanceAndSource({}) + const {getCurrent, subscribe} = useMemo( + () => getQueryErrorState(instance, {source}), + [instance, source], + ) const error = useSyncExternalStore(subscribe, getCurrent) - const clear = useCallback(() => clearQueryError(instance), [instance]) + const clear = useCallback(() => clearQueryError(instance, {source}), [instance, source]) const value = useMemo(() => { if (!(error instanceof CorsOriginError)) return {error: null, projectId: null} diff --git a/packages/react/src/hooks/paginatedDocuments/usePaginatedDocuments.ts b/packages/react/src/hooks/paginatedDocuments/usePaginatedDocuments.ts index 7ce6fb189..36aef9fbf 100644 --- a/packages/react/src/hooks/paginatedDocuments/usePaginatedDocuments.ts +++ b/packages/react/src/hooks/paginatedDocuments/usePaginatedDocuments.ts @@ -1,10 +1,10 @@ -import {createGroqSearchFilter, type DocumentHandle, type QueryOptions} from '@sanity/sdk' +import {createGroqSearchFilter, type DocumentHandle} from '@sanity/sdk' import {type SortOrderingItem} from '@sanity/types' import {pick} from 'lodash-es' import {useCallback, useEffect, useMemo, useState} from 'react' import {useSanityInstance} from '../context/useSanityInstance' -import {useQuery} from '../query/useQuery' +import {useQuery, type UseQueryOptions} from '../query/useQuery' /** * Configuration options for the usePaginatedDocuments hook @@ -12,11 +12,8 @@ import {useQuery} from '../query/useQuery' * @public * @category Types */ -export interface PaginatedDocumentsOptions< - TDocumentType extends string = string, - TDataset extends string = string, - TProjectId extends string = string, -> extends Omit, 'query'> { +export interface PaginatedDocumentsOptions + extends Omit, 'query'> { documentType?: TDocumentType | TDocumentType[] /** * GROQ filter expression to apply to the query @@ -232,7 +229,7 @@ export function usePaginatedDocuments< orderings, search, ...options -}: PaginatedDocumentsOptions): PaginatedDocumentsResponse< +}: PaginatedDocumentsOptions): PaginatedDocumentsResponse< TDocumentType, TDataset, TProjectId diff --git a/packages/react/src/hooks/query/useQuery.ts b/packages/react/src/hooks/query/useQuery.ts index b0e3612ac..60ca421ea 100644 --- a/packages/react/src/hooks/query/useQuery.ts +++ b/packages/react/src/hooks/query/useQuery.ts @@ -8,7 +8,19 @@ import { import {type SanityQueryResult} from 'groq' import {useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition} from 'react' -import {useSanityInstance} from '../context/useSanityInstance' +import {type SourceOptions} from '../../type' +import {useSanityInstanceAndSource} from '../context/useSanityInstance' + +export type UseQueryOptions = Omit< + QueryOptions, + 'source' | 'perspective' +> & { + /** + * The perspective used for this query. If not given, it will use the current perspective + * as given by or . + */ + perspective?: QueryOptions['perspective'] +} & SourceOptions // Overload 1: Inferred Type (using Typegen) /** @@ -71,7 +83,7 @@ export function useQuery< TDataset extends string = string, TProjectId extends string = string, >( - options: QueryOptions, + options: UseQueryOptions, ): { /** The query result, typed based on the GROQ query string */ data: SanityQueryResult @@ -108,7 +120,7 @@ export function useQuery< * } * ``` */ -export function useQuery(options: QueryOptions): { +export function useQuery(options: UseQueryOptions): { /** The query result, cast to the provided type TData */ data: TData /** True if another query is resolving in the background (suspense handles the initial loading state) */ @@ -133,15 +145,17 @@ export function useQuery(options: QueryOptions): { * * @category GROQ */ -export function useQuery(options: QueryOptions): {data: unknown; isPending: boolean} { +export function useQuery(options: UseQueryOptions): {data: unknown; isPending: boolean} { // Implementation returns unknown, overloads define specifics - const instance = useSanityInstance(options) + const [instance, source] = useSanityInstanceAndSource(options) + + const perspective = options.perspective ?? instance.config.perspective ?? 'drafts' // Use React's useTransition to avoid UI jank when queries change const [isPending, startTransition] = useTransition() // Get the unique key for this query and its options - const queryKey = getQueryKey(options) + const queryKey = getQueryKey({...options, perspective}) // Use a deferred state to avoid immediate re-renders when the query changes const [deferredQueryKey, setDeferredQueryKey] = useState(queryKey) // Parse the deferred query key back into a query and options @@ -167,8 +181,8 @@ export function useQuery(options: QueryOptions): {data: unknown; isPending: bool // Get the state source for this query from the query store const {getCurrent, subscribe} = useMemo( - () => getQueryState(instance, deferred), - [instance, deferred], + () => getQueryState(instance, {...deferred, source}), + [instance, source, deferred], ) // If data isn't available yet, suspend rendering @@ -183,7 +197,7 @@ export function useQuery(options: QueryOptions): {data: unknown; isPending: bool // Thus, the promise thrown here uses a stable abort signal, ensuring correct behavior. const currentSignal = ref.current.signal - throw resolveQuery(instance, {...deferred, signal: currentSignal}) + throw resolveQuery(instance, {...deferred, source, signal: currentSignal}) } // Subscribe to updates and get the current data diff --git a/packages/react/src/type.ts b/packages/react/src/type.ts new file mode 100644 index 000000000..93cf5343a --- /dev/null +++ b/packages/react/src/type.ts @@ -0,0 +1,27 @@ +import {type DocumentSource} from '@sanity/sdk' + +/** + * Option which can be used for deciding which source to traget. + * + * This uses the following strategy: + * + * 1. If `source` is given it will use this source and ignore the other parameters. + * 2. If `dataset` or `projectId` is given it will look inside the current Sanity instances for them. + * 3. Otherwise, it uses the current projectId/dataset on the current Sanity instances. + */ +export type SourceOptions = { + /** + * The source (e.g. dataset) which will be queried. + */ + source?: DocumentSource + + /** + * Overrides the project ID for this query. + */ + projectId?: string + + /** + * Overrides the project ID for this query. + */ + dataset?: string +}