From a71e822ef8b0a8211e313647db0b524c136fd393 Mon Sep 17 00:00:00 2001 From: Magnus Holm Date: Wed, 24 Sep 2025 11:44:11 +0200 Subject: [PATCH] feat: add support for source to perspective store A non-breaking change to `usePerspective` which adds support for the `source` parameter. This is paired up with a breaking change to the release/perspective handling in core which now no longer looks at SanityInstance. In the future this will then open us for us having an independent "perspective" context provided by the React package. --- packages/core/src/query/queryStore.ts | 5 +- .../src/releases/getPerspectiveState.test.ts | 47 +++++-------------- .../core/src/releases/getPerspectiveState.ts | 20 ++++---- .../src/hooks/releases/usePerspective.ts | 39 +++++++-------- 4 files changed, 43 insertions(+), 68 deletions(-) diff --git a/packages/core/src/query/queryStore.ts b/packages/core/src/query/queryStore.ts index 9d62f8687..1bd4c42fd 100644 --- a/packages/core/src/query/queryStore.ts +++ b/packages/core/src/query/queryStore.ts @@ -23,7 +23,7 @@ import { } from 'rxjs' import {getClientState} from '../client/clientStore' -import {type DocumentSource, type ReleasePerspective} from '../config/sanityConfig' +import {type DocumentSource, type ReleasePerspective, sourceFor} from '../config/sanityConfig' import {getPerspectiveState} from '../releases/getPerspectiveState' import {bindActionByDataset, type BoundDatasetKey} from '../store/createActionBinder' import {type SanityInstance} from '../store/createSanityInstance' @@ -137,8 +137,7 @@ const listenForNewSubscribersAndFetch = ({ const perspective$ = getPerspectiveState(instance, { perspective: perspectiveFromOptions, - projectId, - dataset, + source: sourceFor({projectId, dataset}), }).observable.pipe(filter(Boolean)) const client$ = getClientState(instance, { diff --git a/packages/core/src/releases/getPerspectiveState.test.ts b/packages/core/src/releases/getPerspectiveState.test.ts index 2f8f27739..70a8c9ffb 100644 --- a/packages/core/src/releases/getPerspectiveState.test.ts +++ b/packages/core/src/releases/getPerspectiveState.test.ts @@ -1,7 +1,7 @@ import {filter, firstValueFrom, of, Subject, take} from 'rxjs' import {describe, expect, it, vi} from 'vitest' -import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig' +import {type ReleasePerspective, sourceFor} from '../config/sanityConfig' import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance' import {listenQuery as mockListenQuery} from '../utils/listenQuery' import {getPerspectiveState} from './getPerspectiveState' @@ -20,6 +20,7 @@ vi.mock('../client/clientStore', () => ({ })) describe('getPerspectiveState', () => { + const source = sourceFor({projectId: 'test', dataset: 'test'}) let instance: SanityInstance let mockReleasesQuerySubject: Subject @@ -53,40 +54,22 @@ describe('getPerspectiveState', () => { vi.clearAllMocks() }) - it('should return default perspective if no options or instance perspective is provided', async () => { - const state = getPerspectiveState(instance, {}) - mockReleasesQuerySubject.next([]) - const perspective = await firstValueFrom(state.observable) - expect(perspective).toBe('drafts') - }) - - it('should return instance perspective if provided and no options perspective', async () => { - instance.config.perspective = 'published' - const state = getPerspectiveState(instance, {}) - mockReleasesQuerySubject.next([]) - const perspective = await firstValueFrom(state.observable) - expect(perspective).toBe('published') - }) - it('should return options perspective if provided', async () => { - const options: PerspectiveHandle = {perspective: 'raw'} - const state = getPerspectiveState(instance, options) + const state = getPerspectiveState(instance, {perspective: 'raw', source}) mockReleasesQuerySubject.next([]) const perspective = await firstValueFrom(state.observable) expect(perspective).toBe('raw') }) it('should return undefined if release perspective is requested but no active releases', async () => { - const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}} - const state = getPerspectiveState(instance, options) + const state = getPerspectiveState(instance, {perspective: {releaseName: 'release1'}, source}) mockReleasesQuerySubject.next([]) const perspective = await firstValueFrom(state.observable) expect(perspective).toBeUndefined() }) it('should calculate perspective based on active releases and releaseName', async () => { - const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}} - const state = getPerspectiveState(instance, options) + const state = getPerspectiveState(instance, {perspective: {releaseName: 'release1'}, source}) mockReleasesQuerySubject.next(activeReleases) const perspective = await firstValueFrom( @@ -99,8 +82,7 @@ describe('getPerspectiveState', () => { }) it('should calculate perspective including multiple releases up to the specified releaseName', async () => { - const options: PerspectiveHandle = {perspective: {releaseName: 'release2'}} - const state = getPerspectiveState(instance, options) + const state = getPerspectiveState(instance, {perspective: {releaseName: 'release2'}, source}) mockReleasesQuerySubject.next(activeReleases) const perspective = await firstValueFrom( state.observable.pipe( @@ -116,8 +98,7 @@ describe('getPerspectiveState', () => { releaseName: 'release2', excludedPerspectives: ['drafts', 'release1'], } - const options: PerspectiveHandle = {perspective: perspectiveConfig} - const state = getPerspectiveState(instance, options) + const state = getPerspectiveState(instance, {perspective: perspectiveConfig, source}) mockReleasesQuerySubject.next(activeReleases) const perspective = await firstValueFrom( state.observable.pipe( @@ -129,8 +110,7 @@ describe('getPerspectiveState', () => { }) it('should throw if the specified releaseName is not found in active releases', async () => { - const options: PerspectiveHandle = {perspective: {releaseName: 'nonexistent'}} - const state = getPerspectiveState(instance, options) + const state = getPerspectiveState(instance, {perspective: {releaseName: 'nonexistent'}, source}) mockReleasesQuerySubject.next(activeReleases) await expect( @@ -144,10 +124,10 @@ describe('getPerspectiveState', () => { }) it('should reuse the same options object for identical inputs (cache test)', async () => { - const options1: PerspectiveHandle = {perspective: {releaseName: 'release1'}} - const options2: PerspectiveHandle = {perspective: {releaseName: 'release1'}} + const options1 = {perspective: {releaseName: 'release1'}} + const options2 = {perspective: {releaseName: 'release1'}} - const state1 = getPerspectiveState(instance, options1) + const state1 = getPerspectiveState(instance, {...options1, source}) mockReleasesQuerySubject.next(activeReleases) await firstValueFrom( state1.observable.pipe( @@ -156,15 +136,14 @@ describe('getPerspectiveState', () => { ), ) - const state2 = getPerspectiveState(instance, options2) + const state2 = getPerspectiveState(instance, {...options2, source}) const perspective2 = state2.getCurrent() expect(perspective2).toEqual(['drafts', 'release1']) }) it('should handle changes in activeReleases (cache test)', async () => { - const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}} - + const options = {perspective: {releaseName: 'release1'}, source} const state1 = getPerspectiveState(instance, options) mockReleasesQuerySubject.next(activeReleases) const perspective1 = await firstValueFrom( diff --git a/packages/core/src/releases/getPerspectiveState.ts b/packages/core/src/releases/getPerspectiveState.ts index ccaa46d04..a8cc2cb6b 100644 --- a/packages/core/src/releases/getPerspectiveState.ts +++ b/packages/core/src/releases/getPerspectiveState.ts @@ -1,6 +1,11 @@ +import {type ClientPerspective} from '@sanity/client' import {createSelector} from 'reselect' -import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig' +import { + type DocumentSource, + type PerspectiveHandle, + type ReleasePerspective, +} from '../config/sanityConfig' import {bindActionByDataset} from '../store/createActionBinder' import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction' import {releasesStore, type ReleasesStoreState} from './releasesStore' @@ -12,18 +17,14 @@ function isReleasePerspective( return typeof perspective === 'object' && perspective !== null && 'releaseName' in perspective } -const DEFAULT_PERSPECTIVE = 'drafts' - // Cache for options const optionsCache = new Map>() -const selectInstancePerspective = (context: SelectorContext) => - context.instance.config.perspective const selectActiveReleases = (context: SelectorContext) => context.state.activeReleases const selectOptions = ( _context: SelectorContext, - options: PerspectiveHandle & {projectId?: string; dataset?: string}, + options: {perspective: ClientPerspective | ReleasePerspective; source: DocumentSource}, ) => options const memoizedOptionsSelector = createSelector( @@ -66,10 +67,9 @@ export const getPerspectiveState = bindActionByDataset( releasesStore, createStateSourceAction({ selector: createSelector( - [selectInstancePerspective, selectActiveReleases, memoizedOptionsSelector], - (instancePerspective, activeReleases, memoizedOptions) => { - const perspective = - memoizedOptions?.perspective ?? instancePerspective ?? DEFAULT_PERSPECTIVE + [selectActiveReleases, memoizedOptionsSelector], + (activeReleases, memoizedOptions) => { + const perspective = memoizedOptions.perspective if (!isReleasePerspective(perspective)) return perspective diff --git a/packages/react/src/hooks/releases/usePerspective.ts b/packages/react/src/hooks/releases/usePerspective.ts index 568ff0d4e..32d1875be 100644 --- a/packages/react/src/hooks/releases/usePerspective.ts +++ b/packages/react/src/hooks/releases/usePerspective.ts @@ -1,19 +1,14 @@ -import { - getActiveReleasesState, - getPerspectiveState, - type PerspectiveHandle, - type SanityInstance, - type StateSource, -} from '@sanity/sdk' -import {filter, firstValueFrom} from 'rxjs' +import {type DocumentSource, getPerspectiveState, type PerspectiveHandle} from '@sanity/sdk' +import {useMemo} from 'react' -import {createStateSourceHook} from '../helpers/createStateSourceHook' +import {useSanityInstanceAndSource} from '../context/useSanityInstance' +import {useStoreState} from '../helpers/useStoreState' /** * @public */ type UsePerspective = { - (perspectiveHandle: PerspectiveHandle): string | string[] + (perspectiveHandle: PerspectiveHandle & {source?: DocumentSource}): string | string[] } /** @@ -30,7 +25,7 @@ type UsePerspective = { * ```tsx * import {usePerspective, useQuery} from '@sanity/sdk-react' - * const perspective = usePerspective({perspective: 'rxg1346', projectId: 'abc123', dataset: 'production'}) + * const perspective = usePerspective({perspective: 'rxg1346'}) * const {data} = useQuery('*[_type == "movie"]', { * perspective: perspective, * }) @@ -38,13 +33,15 @@ type UsePerspective = { * * @returns The perspective for the given perspective handle. */ -export const usePerspective: UsePerspective = createStateSourceHook({ - getState: getPerspectiveState as ( - instance: SanityInstance, - perspectiveHandle?: PerspectiveHandle, - ) => StateSource, - shouldSuspend: (instance: SanityInstance, options: PerspectiveHandle): boolean => - getPerspectiveState(instance, options).getCurrent() === undefined, - suspender: (instance: SanityInstance, _options?: PerspectiveHandle) => - firstValueFrom(getActiveReleasesState(instance, {}).observable.pipe(filter(Boolean))), -}) +export const usePerspective: UsePerspective = ({perspective, source}) => { + const [instance, actualSource] = useSanityInstanceAndSource({source}) + + const actualPerspective = perspective ?? instance.config.perspective ?? 'drafts' + + const state = useMemo( + () => getPerspectiveState(instance, {perspective: actualPerspective, source: actualSource}), + [instance, actualPerspective, actualSource], + ) + + return useStoreState(state) +}