diff --git a/packages/sanity/src/_singletons/core/bundles/BundlesContext.ts b/packages/sanity/src/_singletons/core/bundles/BundlesContext.ts deleted file mode 100644 index ad9e32e1a95..00000000000 --- a/packages/sanity/src/_singletons/core/bundles/BundlesContext.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {createContext} from 'react' - -import type {BundlesContextValue} from '../../../core/store/bundles/BundlesProvider' - -/** - * @internal - */ -export const BundlesContext = createContext(null) diff --git a/packages/sanity/src/_singletons/index.ts b/packages/sanity/src/_singletons/index.ts index 2231ecd090d..cf735122853 100644 --- a/packages/sanity/src/_singletons/index.ts +++ b/packages/sanity/src/_singletons/index.ts @@ -1,4 +1,3 @@ -export * from './core/bundles/BundlesContext' export * from './core/changeIndicators/ConnectorContext' export * from './core/components/previewCard/PreviewCardContext' export * from './core/components/scroll/scrollContext' diff --git a/packages/sanity/src/core/bundles/components/panes/BundleActions.tsx b/packages/sanity/src/core/bundles/components/panes/BundleActions.tsx index eb371d23e05..b47346305b7 100644 --- a/packages/sanity/src/core/bundles/components/panes/BundleActions.tsx +++ b/packages/sanity/src/core/bundles/components/panes/BundleActions.tsx @@ -10,7 +10,7 @@ import { } from 'sanity' import {Button} from '../../../../ui-components' -import {useBundles} from '../../../store/bundles/BundlesProvider' +import {useBundles} from '../../../store/bundles' import {type BundleDocument} from '../../../store/bundles/types' import {getAllVersionsOfDocument, versionDocumentExists} from '../../util/dummyGetters' diff --git a/packages/sanity/src/core/bundles/hooks/usePerspective.tsx b/packages/sanity/src/core/bundles/hooks/usePerspective.tsx index 2110bbbc163..91b92e0521b 100644 --- a/packages/sanity/src/core/bundles/hooks/usePerspective.tsx +++ b/packages/sanity/src/core/bundles/hooks/usePerspective.tsx @@ -1,6 +1,6 @@ import {useRouter} from 'sanity/router' -import {useBundles} from '../../store/bundles/BundlesProvider' +import {useBundles} from '../../store/bundles' import {type BundleDocument} from '../../store/bundles/types' import {LATEST} from '../util/const' diff --git a/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx b/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx index 03595b686be..e98bcbba5bb 100644 --- a/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx +++ b/packages/sanity/src/core/releases/plugin/ReleasesStudioLayout.tsx @@ -1,5 +1,4 @@ import {type LayoutProps} from '../../config' -import {BundlesProvider} from '../../store/bundles/BundlesProvider' import {AddonDatasetProvider} from '../../studio' export function ReleasesStudioLayout(props: LayoutProps) { @@ -10,9 +9,5 @@ export function ReleasesStudioLayout(props: LayoutProps) { return props.renderDefault(props) } - return ( - - {props.renderDefault(props)} - - ) + return {props.renderDefault(props)} } diff --git a/packages/sanity/src/core/releases/tool/BundleDetail.tsx b/packages/sanity/src/core/releases/tool/BundleDetail.tsx index b738f962eb0..7791d72b076 100644 --- a/packages/sanity/src/core/releases/tool/BundleDetail.tsx +++ b/packages/sanity/src/core/releases/tool/BundleDetail.tsx @@ -5,7 +5,7 @@ import {LoadingBlock} from 'sanity' import {useRouter} from 'sanity/router' import {Button as StudioButton} from '../../../ui-components' -import {useBundles} from '../../store/bundles/BundlesProvider' +import {useBundles} from '../../store/bundles' import {BundleMenuButton} from '../components/BundleMenuButton/BundleMenuButton' import {type ReleasesRouterState} from '../types/router' diff --git a/packages/sanity/src/core/releases/tool/BundlesOverview.tsx b/packages/sanity/src/core/releases/tool/BundlesOverview.tsx index 42c89657441..b0babbd3d60 100644 --- a/packages/sanity/src/core/releases/tool/BundlesOverview.tsx +++ b/packages/sanity/src/core/releases/tool/BundlesOverview.tsx @@ -6,7 +6,7 @@ import {type MouseEventHandler, useCallback, useEffect, useMemo, useState} from import {Button as StudioButton} from '../../../ui-components' import {CreateBundleDialog} from '../../bundles/components/dialog/CreateBundleDialog' import {LoadingBlock} from '../../components/loadingBlock/LoadingBlock' -import {useBundles} from '../../store/bundles/BundlesProvider' +import {useBundles} from '../../store/bundles' import {type BundleDocument} from '../../store/bundles/types' import {BundlesTable} from '../components/BundlesTable/BundlesTable' import {containsBundles} from '../types/bundle' diff --git a/packages/sanity/src/core/releases/tool/__tests__/BundlesOverview.test.tsx b/packages/sanity/src/core/releases/tool/__tests__/BundlesOverview.test.tsx index 1d87ac029fb..cd7ecf592ac 100644 --- a/packages/sanity/src/core/releases/tool/__tests__/BundlesOverview.test.tsx +++ b/packages/sanity/src/core/releases/tool/__tests__/BundlesOverview.test.tsx @@ -4,8 +4,7 @@ import {type ReactNode} from 'react' import {queryByDataUi} from '../../../../../test/setup/customQueries' import {createTestProvider} from '../../../../../test/testUtils/TestProvider' -import {useBundlesStore} from '../../../store/bundles' -import {BundlesProvider} from '../../../store/bundles/BundlesProvider' +import {useBundles} from '../../../store/bundles' import {type BundleDocument} from '../../../store/bundles/types' import {releasesUsEnglishLocaleBundle} from '../../i18n' import BundlesOverview from '../BundlesOverview' @@ -15,8 +14,8 @@ jest.mock('../../../store/bundles/useBundleOperations', () => ({ useBundleOperations: jest.fn().mockReturnValue({deleteBundle: jest.fn()}), })) -jest.mock('../../../store/bundles/useBundlesStore', () => ({ - useBundlesStore: jest.fn(), +jest.mock('../../../store/bundles', () => ({ + useBundles: jest.fn(), })) jest.mock('sanity/router', () => ({ @@ -29,15 +28,11 @@ const createWrapper = async () => { resources: [releasesUsEnglishLocaleBundle], }) return function Wrapper({children}: {children: ReactNode}) { - return ( - - {children} - - ) + return {children} } } -const mockUseBundleStore = useBundlesStore as jest.Mock +const mockUseBundleStore = useBundles as jest.Mock describe('BundlesOverview', () => { describe('when loading bundles', () => { @@ -45,7 +40,6 @@ describe('BundlesOverview', () => { mockUseBundleStore.mockReturnValue({ data: null, loading: true, - error: null, dispatch: jest.fn(), }) @@ -78,7 +72,6 @@ describe('BundlesOverview', () => { mockUseBundleStore.mockReturnValue({ data: [], loading: false, - error: null, dispatch: jest.fn(), }) const wrapper = await createWrapper() @@ -116,7 +109,6 @@ describe('BundlesOverview', () => { mockUseBundleStore.mockReturnValue({ data: bundles, loading: false, - error: null, dispatch: jest.fn(), }) const wrapper = await createWrapper() diff --git a/packages/sanity/src/core/store/_legacy/datastores.ts b/packages/sanity/src/core/store/_legacy/datastores.ts index c86b76bcede..9cbd18a76a6 100644 --- a/packages/sanity/src/core/store/_legacy/datastores.ts +++ b/packages/sanity/src/core/store/_legacy/datastores.ts @@ -5,8 +5,10 @@ import {of} from 'rxjs' import {useClient, useSchema, useTemplates} from '../../hooks' import {createDocumentPreviewStore, type DocumentPreviewStore} from '../../preview' -import {useSource, useWorkspace} from '../../studio' +import {useAddonDataset, useSource, useWorkspace} from '../../studio' import {DEFAULT_STUDIO_CLIENT_OPTIONS} from '../../studioClient' +import {createBundlesStore} from '../bundles/createBundlesStore' +import {type BundlesStore} from '../bundles/types' import {createKeyValueStore, type KeyValueStore} from '../key-value' import {useCurrentUser} from '../user' import { @@ -273,3 +275,26 @@ export function useKeyValueStore(): KeyValueStore { return keyValueStore }, [client, resourceCache, workspace]) } + +/** @internal */ +export function useBundlesStore(): BundlesStore { + const resourceCache = useResourceCache() + const workspace = useWorkspace() + const {client} = useAddonDataset() + + return useMemo(() => { + const bundlesStore = + resourceCache.get({ + dependencies: [workspace, client], + namespace: 'BundlesStore', + }) || createBundlesStore({client}) + + resourceCache.set({ + dependencies: [workspace, client], + namespace: 'BundlesStore', + value: bundlesStore, + }) + + return bundlesStore + }, [client, resourceCache, workspace]) +} diff --git a/packages/sanity/src/core/store/bundles/BundlesProvider.tsx b/packages/sanity/src/core/store/bundles/BundlesProvider.tsx deleted file mode 100644 index c616f2f51a2..00000000000 --- a/packages/sanity/src/core/store/bundles/BundlesProvider.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import {type Dispatch, useContext, useMemo} from 'react' -import {BundlesContext} from 'sanity/_singletons' - -import {type bundlesReducerAction} from './reducer' -import {type BundleDocument} from './types' -import {useBundlesStore} from './useBundlesStore' - -interface BundlesProviderProps { - children: React.ReactNode -} - -const EMPTY_ARRAY: [] = [] - -/** - * @internal - */ -export type BundlesContextValue = { - dispatch: Dispatch - loading: boolean - data: BundleDocument[] - error: Error | null -} - -/** - * @internal - */ -export function BundlesProvider(props: BundlesProviderProps) { - const {children} = props - const {data = EMPTY_ARRAY, loading, dispatch, error} = useBundlesStore() - - const value = useMemo( - () => ({ - dispatch, - loading, - data: data ?? [], - error, - }), - [data, dispatch, loading, error], - ) - - return {children} -} - -/** - * @internal - */ -export function useBundles(): BundlesContextValue { - const context = useContext(BundlesContext) - if (!context) { - // TODO: Re consider this, the provider is added when the plugin is inserted - // if users opt out, they won't get the provider, but this return will be called in some core components. - return { - dispatch: () => {}, - loading: false, - data: [], - error: null, - } - } - return context -} diff --git a/packages/sanity/src/core/store/bundles/__workshop__/BundlesStoreStory.tsx b/packages/sanity/src/core/store/bundles/__workshop__/BundlesStoreStory.tsx index b8beb90d135..0cd55fb90e3 100644 --- a/packages/sanity/src/core/store/bundles/__workshop__/BundlesStoreStory.tsx +++ b/packages/sanity/src/core/store/bundles/__workshop__/BundlesStoreStory.tsx @@ -5,16 +5,14 @@ import {Button} from '../../../../ui-components' import {BundleForm} from '../../../bundles/components/dialog/BundleForm' import {LoadingBlock} from '../../../components/loadingBlock/LoadingBlock' import {AddonDatasetProvider} from '../../../studio/addonDataset/AddonDatasetProvider' -import {BundlesProvider, useBundles} from '../BundlesProvider' import {type BundleDocument} from '../types' import {useBundleOperations} from '../useBundleOperations' +import {useBundles} from '../useBundles' const WithAddonDatasetProvider =

(Component: ComponentType

): React.FC

=> { const WrappedComponent: React.FC

= (props) => ( - - - + ) WrappedComponent.displayName = `WithAddonDatasetProvider(${Component.displayName || Component.name || 'Component'})` diff --git a/packages/sanity/src/core/store/bundles/createBundlesStore.ts b/packages/sanity/src/core/store/bundles/createBundlesStore.ts new file mode 100644 index 00000000000..6ec0334df8f --- /dev/null +++ b/packages/sanity/src/core/store/bundles/createBundlesStore.ts @@ -0,0 +1,234 @@ +import {type ListenEvent, type ListenOptions, type SanityClient} from '@sanity/client' +import { + BehaviorSubject, + catchError, + concatWith, + delay, + EMPTY, + filter, + map, + merge, + type Observable, + of, + retry, + scan, + shareReplay, + skip, + startWith, + Subject, + switchMap, + tap, + timeout, +} from 'rxjs' + +import {bundlesReducer, type bundlesReducerAction, type bundlesReducerState} from './reducer' +import {type BundleDocument, type BundlesStore} from './types' + +type ActionWrapper = {action: bundlesReducerAction} +type EventWrapper = {event: ListenEvent} +type ResponseWrapper = {response: BundleDocument[]} + +export const SORT_FIELD = '_createdAt' +export const SORT_ORDER = 'desc' + +const QUERY_FILTERS = [`_type == "bundle"`] + +// TODO: Extend the projection with the fields needed +const QUERY_PROJECTION = `{ + ..., +}` + +// Newest bundles first +const QUERY_SORT_ORDER = `order(${SORT_FIELD} ${SORT_ORDER})` + +const QUERY = `*[${QUERY_FILTERS.join(' && ')}] ${QUERY_PROJECTION} | ${QUERY_SORT_ORDER}` + +const LISTEN_OPTIONS: ListenOptions = { + events: ['welcome', 'mutation', 'reconnect'], + includeResult: true, + visibility: 'query', + tag: 'bundles.listen', +} + +const INITIAL_STATE: bundlesReducerState = { + bundles: new Map(), + state: 'initialising', +} + +const NOOP_BUNDLES_STORE: BundlesStore = { + state$: EMPTY.pipe(startWith(INITIAL_STATE)), + dispatch: () => undefined, +} + +/** + * The bundles store is initialised lazily when first subscribed to. Upon subscription, it will + * fetch a list of bundles and create a listener to keep the locally held state fresh. + * + * The store is not disposed of when all subscriptions are closed. After it has been initialised, + * it will keep listening for the duration of the app's lifecycle. Subsequent subscriptions will be + * given the latest state upon subscription. + */ +export function createBundlesStore(context: {client: SanityClient | null}): BundlesStore { + const {client} = context + + // While the comments dataset is initialising, this factory function will be called with an empty + // `client` value. Return a noop store while the client is unavailable. + // + // TODO: While the comments dataset is initialising, it incorrectly emits an empty object for the + // client instead of `null`, as the types suggest. Once this is fixed, we can remove the object + // keys length check. + if (!client || Object.keys(client).length === 0) { + return NOOP_BUNDLES_STORE + } + + const dispatch$ = new Subject() + const fetchPending$ = new BehaviorSubject(false) + + function dispatch(action: bundlesReducerAction): void { + dispatch$.next(action) + } + + const listFetch$ = of({ + action: { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: true, + error: undefined, + }, + }, + }).pipe( + // Ignore invocations while the list fetch is pending. + filter(() => !fetchPending$.value), + tap(() => fetchPending$.next(true)), + concatWith( + client.observable.fetch(QUERY, {}, {tag: 'bundles.list'}).pipe( + timeout(10_000), // 10s timeout + retry({ + count: 2, + delay: 1_000, + resetOnSuccess: true, + }), + tap(() => fetchPending$.next(false)), + map((response) => ({response})), + ), + ), + catchError((error) => + of({ + action: { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: false, + error, + }, + }, + }), + ), + switchMap>( + (entry) => { + if ('action' in entry) { + return of(entry.action) + } + + return of( + {type: 'BUNDLES_SET', payload: entry.response}, + { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: false, + error: undefined, + }, + }, + ) + }, + ), + ) + + const listener$ = client.observable.listen(QUERY, {}, LISTEN_OPTIONS).pipe( + map((event) => ({event})), + catchError((error) => + of({ + action: { + type: 'LOADING_STATE_CHANGED', + payload: { + loading: false, + error, + }, + }, + }), + ), + // Skip the first event received upon subscription. This `welcome` event would cause the list + // to be fetched again, which is not desirable immediately after the initial fetch has occurred. + skip(1), + // Ignore events emitted while the list fetch is pending. + filter(() => !fetchPending$.value), + switchMap>( + (entry) => { + if ('action' in entry) { + return of(entry.action) + } + + const {event} = entry + + // After successful reconnection, fetch the list. Note that the first event is skipped, so + // this will not occur upon initial connection. + if (event.type === 'welcome') { + return listFetch$.pipe(delay(1_000)) + } + + // The reconnect event means that we are trying to reconnect to the realtime listener. + // In this case we set loading to true to indicate that we're trying to + // reconnect. Once a connection has been established, the welcome event + // will be received and we'll fetch all bundles again (above) + if (event.type === 'reconnect') { + return of({ + type: 'LOADING_STATE_CHANGED', + payload: { + loading: true, + error: undefined, + }, + }) + } + + // Handle mutations (create, update, delete) from the realtime listener + // and update the bundles store accordingly + if (event.type === 'mutation') { + if (event.transition === 'disappear') { + return of({type: 'BUNDLE_DELETED', id: event.documentId}) + } + + if (event.transition === 'appear') { + const nextBundle = event.result + + if (nextBundle) { + return of({type: 'BUNDLE_RECEIVED', payload: nextBundle}) + } + + return of(undefined) + } + + if (event.transition === 'update') { + const updatedBundle = event.result + + if (updatedBundle) { + return of({type: 'BUNDLE_UPDATED', payload: updatedBundle}) + } + } + } + + return of(undefined) + }, + ), + ) + + const state$ = merge(listFetch$, listener$, dispatch$).pipe( + filter((action): action is bundlesReducerAction => typeof action !== 'undefined'), + scan((state, action) => bundlesReducer(state, action), INITIAL_STATE), + startWith(INITIAL_STATE), + shareReplay(1), + ) + + return { + state$, + dispatch, + } +} diff --git a/packages/sanity/src/core/store/bundles/index.ts b/packages/sanity/src/core/store/bundles/index.ts index 020df659155..0a584656d22 100644 --- a/packages/sanity/src/core/store/bundles/index.ts +++ b/packages/sanity/src/core/store/bundles/index.ts @@ -1,3 +1,2 @@ -export * from './BundlesProvider' export * from './types' -export {useBundlesStore} from './useBundlesStore' +export * from './useBundles' diff --git a/packages/sanity/src/core/store/bundles/reducer.ts b/packages/sanity/src/core/store/bundles/reducer.ts index 631664b82e6..f7a3df1ff79 100644 --- a/packages/sanity/src/core/store/bundles/reducer.ts +++ b/packages/sanity/src/core/store/bundles/reducer.ts @@ -16,7 +16,7 @@ interface BundleUpdatedAction { } interface BundlesSetAction { - payload: BundleDocument[] + payload: BundleDocument[] | null type: 'BUNDLES_SET' } @@ -25,23 +25,33 @@ interface BundleReceivedAction { type: 'BUNDLE_RECEIVED' } +interface LoadingStateChangedAction { + payload: { + loading: boolean + error: Error | undefined + } + type: 'LOADING_STATE_CHANGED' +} + export type bundlesReducerAction = | BundleAddedAction | BundleDeletedAction | BundleUpdatedAction | BundlesSetAction | BundleReceivedAction + | LoadingStateChangedAction export interface bundlesReducerState { bundles: Map + state: 'initialising' | 'loading' | 'loaded' | 'error' + error?: Error } -function createBundlesSet(bundles: BundleDocument[]) { - const bundlesById = bundles.reduce((acc, bundle) => { +function createBundlesSet(bundles: BundleDocument[] | null) { + return (bundles ?? []).reduce((acc, bundle) => { acc.set(bundle._id, bundle) return acc }, new Map()) - return bundlesById } export function bundlesReducer( @@ -49,6 +59,14 @@ export function bundlesReducer( action: bundlesReducerAction, ): bundlesReducerState { switch (action.type) { + case 'LOADING_STATE_CHANGED': { + return { + ...state, + state: action.payload.loading ? 'loading' : 'loaded', + error: action.payload.error, + } + } + case 'BUNDLES_SET': { // Create an object with the BUNDLE id as key const bundlesById = createBundlesSet(action.payload) diff --git a/packages/sanity/src/core/store/bundles/types.ts b/packages/sanity/src/core/store/bundles/types.ts index fd9121b0954..230d005eecc 100644 --- a/packages/sanity/src/core/store/bundles/types.ts +++ b/packages/sanity/src/core/store/bundles/types.ts @@ -1,6 +1,10 @@ import {type ColorHueKey} from '@sanity/color' import {type IconSymbol} from '@sanity/icons' import {type SanityDocument} from '@sanity/types' +import {type Dispatch} from 'react' +import {type Observable} from 'rxjs' + +import {type bundlesReducerAction, type bundlesReducerState} from './reducer' /** * @internal @@ -23,3 +27,11 @@ export interface BundleDocument extends SanityDocument { export function isBundleDocument(doc: unknown): doc is BundleDocument { return typeof doc === 'object' && doc !== null && '_type' in doc && doc._type === 'bundle' } + +/** + * @internal + */ +export interface BundlesStore { + state$: Observable + dispatch: Dispatch +} diff --git a/packages/sanity/src/core/store/bundles/useBundles.ts b/packages/sanity/src/core/store/bundles/useBundles.ts new file mode 100644 index 00000000000..33c3991e97c --- /dev/null +++ b/packages/sanity/src/core/store/bundles/useBundles.ts @@ -0,0 +1,29 @@ +import {useMemo} from 'react' +import {useObservable} from 'react-rx' + +import {useBundlesStore} from '../_legacy/datastores' +import {type bundlesReducerAction} from './reducer' +import {type BundleDocument} from './types' + +interface BundlesState { + data: BundleDocument[] | null + error?: Error + loading: boolean + dispatch: React.Dispatch +} + +/** + * @internal + */ +export function useBundles(): BundlesState { + const {state$, dispatch} = useBundlesStore() + const state = useObservable(state$)! + const bundlesAsArray = useMemo(() => Array.from(state.bundles.values()), [state.bundles]) + + return { + data: bundlesAsArray, + dispatch, + error: state.error, + loading: ['loading', 'initialising'].includes(state.state), + } +} diff --git a/packages/sanity/src/core/store/bundles/useBundlesStore.ts b/packages/sanity/src/core/store/bundles/useBundlesStore.ts deleted file mode 100644 index 0040dd2be9c..00000000000 --- a/packages/sanity/src/core/store/bundles/useBundlesStore.ts +++ /dev/null @@ -1,152 +0,0 @@ -import {type ListenEvent, type ListenOptions} from '@sanity/client' -import {useCallback, useMemo, useReducer, useRef, useState} from 'react' -import {useObservable} from 'react-rx' -import {catchError, concatMap, map, of, retry, timeout} from 'rxjs' - -import {useAddonDataset} from '../../studio/addonDataset/useAddonDataset' -import {bundlesReducer, type bundlesReducerAction, type bundlesReducerState} from './reducer' -import {type BundleDocument} from './types' - -interface BundlesStoreReturnType { - data: BundleDocument[] | null - error: Error | null - loading: boolean - dispatch: React.Dispatch -} - -const INITIAL_STATE: bundlesReducerState = { - bundles: new Map(), -} - -const LISTEN_OPTIONS: ListenOptions = { - events: ['welcome', 'mutation', 'reconnect'], - includeResult: true, - visibility: 'query', -} - -export const SORT_FIELD = '_createdAt' -export const SORT_ORDER = 'desc' - -const QUERY_FILTERS = [`_type == "bundle"`] - -// TODO: Extend the projection with the fields needed -const QUERY_PROJECTION = `{ - ..., -}` - -// Newest bundles first -const QUERY_SORT_ORDER = `order(${SORT_FIELD} ${SORT_ORDER})` - -const QUERY = `*[${QUERY_FILTERS.join(' && ')}] ${QUERY_PROJECTION} | ${QUERY_SORT_ORDER}` - -/** - * @internal - */ -export function useBundlesStore(): BundlesStoreReturnType { - const {client} = useAddonDataset() - - const [state, dispatch] = useReducer(bundlesReducer, INITIAL_STATE) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const didInitialFetch = useRef(false) - - const initialFetch$ = useCallback(() => { - if (!client) { - return of(null) // emits null and completes if no client - } - return client.observable.fetch(QUERY).pipe( - timeout(10000), // 10s timeout - map((res) => { - dispatch({type: 'BUNDLES_SET', payload: res}) - didInitialFetch.current = true - setLoading(false) - }), - retry({ - count: 2, - delay: 1000, - }), - catchError((err) => { - if (err.name === 'TimeoutError') { - console.error('Fetch operation timed out:', err) - } - setError(err) - return of(null) // ensure stream completion even on error - }), - ) - }, [client]) - - const handleListenerEvent = useCallback( - (event: ListenEvent>) => { - // Fetch all bundles on initial connection - if (event.type === 'welcome' && !didInitialFetch.current) { - // Do nothing here, the initial fetch is done in the useEffect below - initialFetch$() - } - - // The reconnect event means that we are trying to reconnect to the realtime listener. - // In this case we set loading to true to indicate that we're trying to - // reconnect. Once a connection has been established, the welcome event - // will be received and we'll fetch all bundles again (above) - if (event.type === 'reconnect') { - setLoading(true) - didInitialFetch.current = false - } - - // Handle mutations (create, update, delete) from the realtime listener - // and update the bundles store accordingly - if (event.type === 'mutation' && didInitialFetch.current) { - if (event.transition === 'disappear') { - dispatch({type: 'BUNDLE_DELETED', id: event.documentId}) - } - - if (event.transition === 'appear') { - const nextBundle = event.result as BundleDocument | undefined - - if (nextBundle) { - dispatch({type: 'BUNDLE_RECEIVED', payload: nextBundle}) - } - } - - if (event.transition === 'update') { - const updatedBundle = event.result as BundleDocument | undefined - - if (updatedBundle) { - dispatch({type: 'BUNDLE_UPDATED', payload: updatedBundle}) - } - } - } - }, - [initialFetch$], - ) - - const listener$ = useMemo(() => { - if (!client) return of() - - const events$ = client.observable.listen(QUERY, {}, LISTEN_OPTIONS).pipe( - map(handleListenerEvent), - catchError((err) => { - setError(err) - return of(err) - }), - ) - - return events$ // as Observable>> - }, [client, handleListenerEvent]) - - const observable = useMemo(() => { - if (!client) return of(null) // emits null and completes if no client - return initialFetch$().pipe(concatMap(() => listener$)) - }, [initialFetch$, listener$, client]) - - useObservable(observable) - - const bundlesAsArray = useMemo(() => Array.from(state.bundles.values()), [state.bundles]) - - return { - data: bundlesAsArray, - dispatch, - error, - loading, - } -} diff --git a/packages/sanity/src/core/studio/components/navbar/perspective/GlobalPerspectiveMenu.tsx b/packages/sanity/src/core/studio/components/navbar/perspective/GlobalPerspectiveMenu.tsx index 927cbf7f9f7..d4656f69d16 100644 --- a/packages/sanity/src/core/studio/components/navbar/perspective/GlobalPerspectiveMenu.tsx +++ b/packages/sanity/src/core/studio/components/navbar/perspective/GlobalPerspectiveMenu.tsx @@ -6,7 +6,7 @@ import {BundleBadge} from '../../../../bundles/components/BundleBadge' import {BundleMenu} from '../../../../bundles/components/BundleMenu' import {CreateBundleDialog} from '../../../../bundles/components/dialog/CreateBundleDialog' import {usePerspective} from '../../../../bundles/hooks/usePerspective' -import {useBundles} from '../../../../store/bundles/BundlesProvider' +import {useBundles} from '../../../../store/bundles' export function GlobalPerspectiveMenu(): JSX.Element { const {data: bundles, loading} = useBundles() diff --git a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveMenu.tsx b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveMenu.tsx index ea6b174a694..469b16f59f0 100644 --- a/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveMenu.tsx +++ b/packages/sanity/src/structure/panes/document/documentPanel/header/perspective/DocumentPerspectiveMenu.tsx @@ -8,11 +8,14 @@ import { DEFAULT_STUDIO_CLIENT_OPTIONS, getAllVersionsOfDocument, LATEST, - useBundles, useClient, usePerspective, } from 'sanity' +// FIXME +// eslint-disable-next-line boundaries/element-types +import {useBundles} from '../../../../../../core/store/bundles/useBundles' + export function DocumentPerspectiveMenu(props: {documentId: string}): JSX.Element { const {documentId} = props const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS) diff --git a/packages/sanity/test/testUtils/TestProvider.tsx b/packages/sanity/test/testUtils/TestProvider.tsx index 55aa46a090a..aff03909472 100644 --- a/packages/sanity/test/testUtils/TestProvider.tsx +++ b/packages/sanity/test/testUtils/TestProvider.tsx @@ -3,6 +3,7 @@ import {LayerProvider, studioTheme, ThemeProvider, ToastProvider} from '@sanity/ import {type ReactNode} from 'react' import { + AddonDatasetProvider, LocaleProviderBase, type LocaleResourceBundle, ResourceCacheProvider, @@ -46,7 +47,9 @@ export async function createTestProvider({ - {children} + + {children} +