Repository: sanity-io/sanity
Pull request #9471: refactor(core): move observable cache clean up to helper method
Author: login: RitaDias
Created: 2025-05-21T13:00:47Z | Merged: 2025-05-23T08:15:46Z

### Description

Done as a suggestion for Bjørge of a follow of up #9442 (thank you @bjoerge  🙏 )
Move the logic to a specific function that can be called in other parts of the code as needed. This also makes sure that the cache cleans up the observer once the right time comes

A follow up for soon:
- Noticed as well that some hanging issues still happen though with a quite a few more requests. However, I also noticed that it seems unrelated to the sections we have changed / fix. Will check this on a follow up as well.
- will be to see if we can use this in the areas around createDocumentIdSetObserver (and if it makes sense)
- reduce the number of calls to useDocumentVersion in separate smaller hooks

### What to review

Does this make sense? Am I missing something?

### Testing
Existing automated tests should be enough.

If you want to double check manually:

On safari (where it's easiest to see this happen)

- Open [this](https://test-studio-git-caching-issues-2.sanity.dev/test/structure/custom;deepPanes;deeper;evenDeeper;keepDigging;digIntoTheCoreOfTheEarth;grrm;453ccfc8-da94-4573-aa9c-8253b0773a21%2Ctype%3Dauthor%2CparentRefPath%3DbestFriend) document in five/six tab.
- After the five/six tabs finish loading, on the last tab you should **consistently** be able to see both documents and references loaded without pending requests and fetches

<img width="1693" alt="image" src="https://github.com/user-attachments/assets/600bb7fc-c22d-4542-bde6-d675c4365443" />

### Notes for release

Internal only.



Diff:
diff --git a/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx b/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx
index 42f9fa59793d..1039a5f1b2d5 100644
--- a/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx
+++ b/packages/sanity/src/core/releases/hooks/__tests__/useDocumentVersions.test.tsx
@@ -1,13 +1,17 @@
 import {type ReleaseDocument} from '@sanity/client'
 import {renderHook, waitFor} from '@testing-library/react'
 import {delay, of} from 'rxjs'
-import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
+import {describe, expect, it, type Mock, vi} from 'vitest'
 
 import {type DocumentPreviewStore} from '../../../preview'
 import {type DocumentIdSetObserverState} from '../../../preview/liveDocumentIdSet'
 import {useDocumentPreviewStore} from '../../../store'
 import {activeASAPRelease, activeScheduledRelease} from '../../__fixtures__/release.fixture'
-import {observableCache, useDocumentVersions} from '../useDocumentVersions'
+import {
+  type DocumentPerspectiveState,
+  getOrCreateDocumentVersionsObservable,
+  useDocumentVersions,
+} from '../useDocumentVersions'
 
 vi.mock('../../../hooks/useDataset', () => ({
   useDataset: vi.fn().mockReturnValue('test'),
@@ -17,25 +21,23 @@ vi.mock('../../../hooks/useProjectId', () => ({
   useProjectId: vi.fn().mockReturnValue('test-project'),
 }))
 
-vi.mock('../../store', () => ({
-  useReleasesMetadata: vi.fn(),
-  useActiveReleases: vi.fn(),
-}))
-
-vi.mock('../../store/useReleasesIds', () => ({
-  useReleasesIds: vi.fn(),
-}))
+vi.mock('../useDocumentVersions', async () => {
+  const actual = await vi.importActual('../useDocumentVersions')
+  return {
+    ...actual,
+    getOrCreateDocumentVersionsObservable: vi.fn(),
+  }
+})
 
 vi.mock('../../../store', () => ({
   useDocumentPreviewStore: vi.fn(),
 }))
 
-beforeEach(() => {
-  observableCache.clear()
-})
-
 async function setupMocks({versionIds}: {releases: ReleaseDocument[]; versionIds: string[]}) {
   const mockDocumentPreviewStore = useDocumentPreviewStore as Mock<typeof useDocumentPreviewStore>
+  const mockGetOrCreateDocumentVersionsObservable = getOrCreateDocumentVersionsObservable as Mock<
+    typeof getOrCreateDocumentVersionsObservable
+  >
 
   mockDocumentPreviewStore.mockReturnValue({
     unstable_observeDocumentIdSet: vi
@@ -47,6 +49,14 @@ async function setupMocks({versionIds}: {releases: ReleaseDocument[]; versionIds
         ),
       ),
   } as unknown as DocumentPreviewStore)
+
+  mockGetOrCreateDocumentVersionsObservable.mockImplementation(() =>
+    of({
+      data: versionIds,
+      loading: false,
+      error: null,
+    } as DocumentPerspectiveState).pipe(delay(0)),
+  )
 }
 
 describe('useDocumentVersions', () => {
diff --git a/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx b/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx
index b1d29b6cb20c..ae5a8eabef72 100644
--- a/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx
+++ b/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx
@@ -1,9 +1,10 @@
+import {type QueryParams} from '@sanity/client'
 import {useEffect, useMemo, useState} from 'react'
-import {map, type Observable, of, shareReplay} from 'rxjs'
-import {catchError} from 'rxjs/operators'
+import {catchError, finalize, map, type Observable, of, shareReplay} from 'rxjs'
 
 import {useDataset} from '../../hooks/useDataset'
 import {useProjectId} from '../../hooks/useProjectId'
+import {type DocumentPreviewStore} from '../../preview/documentPreviewStore'
 import {useDocumentPreviewStore} from '../../store'
 import {getPublishedId} from '../../util/draftUtils'
 import {createSWR} from '../../util/rxSwr'
@@ -19,38 +20,15 @@ export interface DocumentPerspectiveState {
   loading: boolean
 }
 
-// Create a singleton cache for observables
-export const observableCache = new Map<string, Observable<DocumentPerspectiveState>>()
-
-const swr = createSWR<{documentIds: string[]}>({maxSize: 100})
-
 const INITIAL_VALUE = {
   data: [],
   error: null,
   loading: true,
 }
 
-function createObservable(
-  documentPreviewStore: any,
-  publishedId: string,
-): Observable<DocumentPerspectiveState> {
-  return documentPreviewStore
-    .unstable_observeDocumentIdSet(`sanity::versionOf("${publishedId}")`, undefined, {
-      apiVersion: RELEASES_STUDIO_CLIENT_OPTIONS.apiVersion,
-    })
-    .pipe(
-      swr(`${publishedId}`),
-      map(({value}) => ({
-        data: value.documentIds,
-        loading: false,
-        error: null,
-      })),
-      catchError((error) => {
-        return of({error, data: [] as string[], loading: false})
-      }),
-      shareReplay({refCount: true, bufferSize: 1}),
-    )
-}
+// Create a singleton cache for observables
+export const observableCache = new Map<string, Observable<DocumentPerspectiveState>>()
+const swr = createSWR<{documentIds: string[]}>({maxSize: 100})
 
 /**
  * Fetches the document versions for a given document
@@ -67,18 +45,15 @@ export function useDocumentVersions(props: DocumentPerspectiveProps): DocumentPe
   const projectId = useProjectId()
   const documentPreviewStore = useDocumentPreviewStore()
   const [results, setResults] = useState<DocumentPerspectiveState>(INITIAL_VALUE)
-  const cacheId = `${projectId}-${dataset}-${publishedId}`
-
-  const observable = useMemo(() => {
-    const cachedObservable = observableCache.get(cacheId)
-    if (cachedObservable) {
-      return cachedObservable
-    }
 
-    const newObservable = createObservable(documentPreviewStore, publishedId)
-    observableCache.set(cacheId, newObservable)
-    return newObservable
-  }, [cacheId, documentPreviewStore, publishedId])
+  const observable: Observable<DocumentPerspectiveState> = useMemo(() => {
+    return getOrCreateDocumentVersionsObservable({
+      documentPreviewStore,
+      publishedId,
+      projectId,
+      dataset,
+    })
+  }, [dataset, documentPreviewStore, projectId, publishedId])
 
   useEffect(() => {
     const subscription = observable.subscribe((result) => {
@@ -89,3 +64,56 @@ export function useDocumentVersions(props: DocumentPerspectiveProps): DocumentPe
 
   return results
 }
+
+/**
+ * Retrieves an observable that emits document IDs matching the document versions that exist for a specific id
+ *
+ * @param options - The options for creating or retrieving the observable.
+ * options.documentPreviewStore - The store used to observe document IDs.
+ * options.params - The query params to use for the observable.
+ * options.publishedId - The ID of the published document.
+ * options.projectId - The project ID.
+ * options.dataset - The dataset name.
+ * @returns An observable that emits the document versions.
+ *
+ * @hidden
+ * @internal
+ */
+export function getOrCreateDocumentVersionsObservable(options: {
+  documentPreviewStore: DocumentPreviewStore
+  params?: QueryParams
+  publishedId: string
+  projectId: string
+  dataset: string
+}): Observable<DocumentPerspectiveState> {
+  const {documentPreviewStore, projectId, dataset, publishedId, params = undefined} = options
+  const cacheKey = `${projectId}-${dataset}-${publishedId}`
+
+  const cachedObservable = observableCache.get(cacheKey)
+  if (cachedObservable) {
+    return cachedObservable
+  }
+
+  const newObservable = documentPreviewStore
+    .unstable_observeDocumentIdSet(`sanity::versionOf("${publishedId}")`, params, {
+      apiVersion: RELEASES_STUDIO_CLIENT_OPTIONS.apiVersion,
+    })
+    .pipe(
+      swr(cacheKey),
+      map(({value}) => ({
+        data: value.documentIds,
+        error: null,
+        loading: false,
+      })),
+      catchError((error) => {
+        return of({error, data: [] as string[], loading: false})
+      }),
+      finalize(() => {
+        observableCache.delete(cacheKey)
+      }),
+      shareReplay({refCount: true, bufferSize: 1}),
+    )
+
+  observableCache.set(cacheKey, newObservable)
+  return newObservable
+}


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T13:01:00Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR extracts the observable creation and caching logic from the `useDocumentVersions` hook into a reusable helper function, centralizing cache management and cleanup.

- Introduces `getOrCreateObservable` helper with shared cache and automatic cleanup on finalize.
- Refactors `useDocumentVersions` to delegate to the new helper and removes inline observable logic.

### Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

| File                                                             | Description                                                                 |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
| packages/sanity/src/core/util/getOrCreateObservable.ts           | Adds helper for creating, caching, and cleaning up document-version observables |
| packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx  | Refactors hook to use `getOrCreateObservable` and removes duplicate code    |


<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**packages/sanity/src/core/util/getOrCreateObservable.ts:10**
* [nitpick] The variable name `swr` is ambiguous; consider renaming it to something more descriptive like `swrOperator` or `swrCacheOperator`.
```
const swr = createSWR<{documentIds: string[]}>({maxSize: 100})
```
**packages/sanity/src/core/util/getOrCreateObservable.ts:9**
* [nitpick] The name `observableCache` is very generic; consider renaming to `documentPerspectiveObservableCache` or similar to clarify its scope and avoid potential collisions.
```
export const observableCache = new Map<string, Observable<DocumentPerspectiveState>>()
```

</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T13:04:12Z
user: login: pedrobonamin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T13:32:08Z
user: login: RitaDias
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T13:33:39Z
user: login: RitaDias
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T13:35:28Z
user: login: pedrobonamin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T14:09:23Z
user: login: bjoerge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T14:31:58Z
user: login: RitaDias
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:48:47Z
user: login: bjoerge
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T17:38:42Z
user: login: bjoerge
body: 🚀 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T08:15:32Z

Review comments:
diff_hunk: @@ -0,0 +1,48 @@
+import {catchError, finalize, map, type Observable, of, shareReplay} from 'rxjs'
+
+import {type DocumentPreviewStore} from '../preview/documentPreviewStore'
+import {type DocumentPerspectiveState} from '../releases/hooks/useDocumentVersions'
+import {RELEASES_STUDIO_CLIENT_OPTIONS} from '../releases/util/releasesClient'
+import {createSWR} from './rxSwr'
+
+// Create a singleton cache for observables
+export const observableCache = new Map<string, Observable<DocumentPerspectiveState>>()
+const swr = createSWR<{documentIds: string[]}>({maxSize: 100})
+
path: packages/sanity/src/core/util/getOrCreateObservable.ts
user: login: Copilot
body: [nitpick] Add a JSDoc comment above `getOrCreateObservable` to explain its purpose, parameters, return type, and caching behavior (including cleanup on finalize).
```suggestion

/**
 * Retrieves an observable for the specified document or creates a new one if it doesn't exist in the cache.
 * 
 * This function uses a caching mechanism to store observables based on a unique cache key
 * (constructed from `projectId`, `dataset`, and `publishedId`). If an observable is already cached,
 * it is returned. Otherwise, a new observable is created using the `documentPreviewStore`.
 * 
 * The observable emits the state of the document perspective, including data, loading status, and errors.
 * When the observable is finalized (e.g., unsubscribed), it is removed from the cache.
 * 
 * @param {Object} options - The options for creating or retrieving the observable.
 * @param {DocumentPreviewStore} options.documentPreviewStore - The store used to observe document IDs.
 * @param {string} options.publishedId - The ID of the published document.
 * @param {string} options.projectId - The project ID.
 * @param {string} options.dataset - The dataset name.
 * @returns {Observable<DocumentPerspectiveState>} An observable that emits the document perspective state.
 */
```
created_at: 2025-05-21T13:04:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,61 @@
+import {catchError, finalize, map, type Observable, of, shareReplay} from 'rxjs'
+
+import {type DocumentPreviewStore} from '../preview/documentPreviewStore'
+import {type DocumentPerspectiveState} from '../releases/hooks/useDocumentVersions'
+import {RELEASES_STUDIO_CLIENT_OPTIONS} from '../releases/util/releasesClient'
+import {createSWR} from './rxSwr'
+
+// Create a singleton cache for observables
+export const observableCache = new Map<string, Observable<DocumentPerspectiveState>>()
+const swr = createSWR<{documentIds: string[]}>({maxSize: 100})
+
+/**
+ * Retrieves an observable for the specified document or creates a new one if it doesn't exist in the cache
+ *
+ * @param options - The options for creating or retrieving the observable.
+ * options.documentPreviewStore - The store used to observe document IDs.
+ * options.publishedId - The ID of the published document.
+ * options.projectId - The project ID.
+ * options.dataset - The dataset name.
+ * @returns An observable that emits the document perspective state.
+ *
+ * @hidden
+ * @internal
+ */
+export function getOrCreateObservable(options: {
path: packages/sanity/src/core/util/getOrCreateObservable.ts
user: login: pedrobonamin
body: Should this be named something more specific indicating it's a `sanity::versionOf` observable?

At first read I thought it was a generic for cached observables
created_at: 2025-05-21T13:32:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,61 @@
+import {catchError, finalize, map, type Observable, of, shareReplay} from 'rxjs'
+
+import {type DocumentPreviewStore} from '../preview/documentPreviewStore'
+import {type DocumentPerspectiveState} from '../releases/hooks/useDocumentVersions'
+import {RELEASES_STUDIO_CLIENT_OPTIONS} from '../releases/util/releasesClient'
+import {createSWR} from './rxSwr'
+
+// Create a singleton cache for observables
+export const observableCache = new Map<string, Observable<DocumentPerspectiveState>>()
+const swr = createSWR<{documentIds: string[]}>({maxSize: 100})
+
+/**
+ * Retrieves an observable for the specified document or creates a new one if it doesn't exist in the cache
+ *
+ * @param options - The options for creating or retrieving the observable.
+ * options.documentPreviewStore - The store used to observe document IDs.
+ * options.publishedId - The ID of the published document.
+ * options.projectId - The project ID.
+ * options.dataset - The dataset name.
+ * @returns An observable that emits the document perspective state.
+ *
+ * @hidden
+ * @internal
+ */
+export function getOrCreateObservable(options: {
path: packages/sanity/src/core/util/getOrCreateObservable.ts
user: login: RitaDias
body: ah you are absolutely right. I'll update this to also receive the prop for the `unstable_observeDocumentIdSet` I think. I don't think it makes sense to make this be specific for `sanity::versionOf` 🤔 
created_at: 2025-05-21T13:33:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,61 @@
+import {catchError, finalize, map, type Observable, of, shareReplay} from 'rxjs'
+
+import {type DocumentPreviewStore} from '../preview/documentPreviewStore'
+import {type DocumentPerspectiveState} from '../releases/hooks/useDocumentVersions'
+import {RELEASES_STUDIO_CLIENT_OPTIONS} from '../releases/util/releasesClient'
+import {createSWR} from './rxSwr'
+
+// Create a singleton cache for observables
+export const observableCache = new Map<string, Observable<DocumentPerspectiveState>>()
+const swr = createSWR<{documentIds: string[]}>({maxSize: 100})
+
+/**
+ * Retrieves an observable for the specified document or creates a new one if it doesn't exist in the cache
+ *
+ * @param options - The options for creating or retrieving the observable.
+ * options.documentPreviewStore - The store used to observe document IDs.
+ * options.publishedId - The ID of the published document.
+ * options.projectId - The project ID.
+ * options.dataset - The dataset name.
+ * @returns An observable that emits the document perspective state.
+ *
+ * @hidden
+ * @internal
+ */
+export function getOrCreateObservable(options: {
path: packages/sanity/src/core/util/getOrCreateObservable.ts
user: login: RitaDias
body: Ah but then there is the problem with the rest of the rxjs calls that come after which are also basically specific to whatever you want to do 🤔 
Ok, I think the way to go will be to send in whatever the request is (so in this case the contents of `newObservable`) as a prop. I think that'll be the way to make this generic
created_at: 2025-05-21T13:35:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,61 @@
+import {catchError, finalize, map, type Observable, of, shareReplay} from 'rxjs'
+
+import {type DocumentPreviewStore} from '../preview/documentPreviewStore'
+import {type DocumentPerspectiveState} from '../releases/hooks/useDocumentVersions'
+import {RELEASES_STUDIO_CLIENT_OPTIONS} from '../releases/util/releasesClient'
+import {createSWR} from './rxSwr'
+
+// Create a singleton cache for observables
+export const observableCache = new Map<string, Observable<DocumentPerspectiveState>>()
+const swr = createSWR<{documentIds: string[]}>({maxSize: 100})
+
+/**
+ * Retrieves an observable for the specified document or creates a new one if it doesn't exist in the cache
+ *
+ * @param options - The options for creating or retrieving the observable.
+ * options.documentPreviewStore - The store used to observe document IDs.
+ * options.publishedId - The ID of the published document.
+ * options.projectId - The project ID.
+ * options.dataset - The dataset name.
+ * @returns An observable that emits the document perspective state.
+ *
+ * @hidden
+ * @internal
+ */
+export function getOrCreateObservable(options: {
path: packages/sanity/src/core/util/getOrCreateObservable.ts
user: login: pedrobonamin
body: I think it's ok to define the observeDocumentIdSet in the function, because if you receive that as a prop then maybe it will cache and return you a different function than what you expected.
created_at: 2025-05-21T14:09:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,72 @@
+import {type QueryParams} from '@sanity/client'
+import {catchError, finalize, map, type Observable, of, shareReplay} from 'rxjs'
+
+import {type DocumentPreviewStore} from '../preview/documentPreviewStore'
+import {createSWR} from './rxSwr'
+
+// Create a singleton cache for observables
+export const observableCache = new Map<string, Observable<any>>()
+const swr = createSWR<any>({maxSize: 100})
+
+/**
+ * Retrieves an observable for the specified document or creates a new one if it doesn't exist in the cache
+ *
+ * @param options - The options for creating or retrieving the observable.
+ * options.documentPreviewStore - The store used to observe document IDs.
+ * options.filter - The filter to use for observing documents.
+ * options.publishedId - The ID of the published document.
+ * options.projectId - The project ID.
+ * options.dataset - The dataset name.
+ * options.apiVersion - The API version to use.
+ * options.mapValue - Function to map the observed value to the desired format.
+ * @returns An observable that emits the mapped document state.
+ *
+ * @hidden
+ * @internal
+ */
+export function getOrCreateObservable<T = any>(options: {
path: packages/sanity/src/core/util/getOrCreateObservable.ts
user: login: bjoerge
body: Hm, I still think the naming here is a bit too generic considering its dependencies and responsibilities. Think it would be preferable it was kept in `useDocumentVersions.tsx` (can still be exported for the tests), wdyt?
created_at: 2025-05-22T14:13:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+import {type QueryParams} from '@sanity/client'
+import {catchError, finalize, map, type Observable, of, shareReplay} from 'rxjs'
+
+import {type DocumentPreviewStore} from '../preview/documentPreviewStore'
+import {createSWR} from './rxSwr'
+
+// Create a singleton cache for observables
+export const observableCache = new Map<string, Observable<any>>()
+const swr = createSWR<any>({maxSize: 100})
+
+/**
+ * Retrieves an observable for the specified document or creates a new one if it doesn't exist in the cache
+ *
+ * @param options - The options for creating or retrieving the observable.
+ * options.documentPreviewStore - The store used to observe document IDs.
+ * options.filter - The filter to use for observing documents.
+ * options.publishedId - The ID of the published document.
+ * options.projectId - The project ID.
+ * options.dataset - The dataset name.
+ * options.apiVersion - The API version to use.
+ * options.mapValue - Function to map the observed value to the desired format.
+ * @returns An observable that emits the mapped document state.
+ *
+ * @hidden
+ * @internal
+ */
+export function getOrCreateObservable<T = any>(options: {
path: packages/sanity/src/core/util/getOrCreateObservable.ts
user: login: RitaDias
body: So not touch the logic but simply allow the observable to be exported? 🤔 
created_at: 2025-05-22T16:48:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,72 @@
+import {type QueryParams} from '@sanity/client'
+import {catchError, finalize, map, type Observable, of, shareReplay} from 'rxjs'
+
+import {type DocumentPreviewStore} from '../preview/documentPreviewStore'
+import {createSWR} from './rxSwr'
+
+// Create a singleton cache for observables
+export const observableCache = new Map<string, Observable<any>>()
+const swr = createSWR<any>({maxSize: 100})
+
+/**
+ * Retrieves an observable for the specified document or creates a new one if it doesn't exist in the cache
+ *
+ * @param options - The options for creating or retrieving the observable.
+ * options.documentPreviewStore - The store used to observe document IDs.
+ * options.filter - The filter to use for observing documents.
+ * options.publishedId - The ID of the published document.
+ * options.projectId - The project ID.
+ * options.dataset - The dataset name.
+ * options.apiVersion - The API version to use.
+ * options.mapValue - Function to map the observed value to the desired format.
+ * @returns An observable that emits the mapped document state.
+ *
+ * @hidden
+ * @internal
+ */
+export function getOrCreateObservable<T = any>(options: {
path: packages/sanity/src/core/util/getOrCreateObservable.ts
user: login: bjoerge
body: yeah, think it's rather confusing the way it now is in a utils folder, because based on where it lives and it's name it sounds like it's a general function that adds caching/memoization to any observable, but in reality it's highly specific and requires quite a lot of contextual arguments to be passed in, so think it's going to be rather tricky to re-use outside of `useDocumentVersions`, which makes it better suited to be colocated with that hook in my view.
created_at: 2025-05-22T17:38:42Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-05-21T13:00:52Z
body: [vc]: #+PhBx/3WtUedkMjsqZzBeLDY9JEYfaZGYP4SS2vhXlQ=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ0ZXN0LXN0dWRpbyIsInJvb3REaXJlY3RvcnkiOm51bGwsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC90ZXN0LXN0dWRpby9IU3dwRndacGh4alJRbnpxV3NTcW9mU0FoMzh3IiwicHJldmlld1VybCI6InRlc3Qtc3R1ZGlvLWdpdC1jYWNoaW5nLWlzc3Vlcy0yLnNhbml0eS5kZXYiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoidGVzdC1zdHVkaW8tZ2l0LWNhY2hpbmctaXNzdWVzLTIuc2FuaXR5LmRldiJ9fSx7Im5hbWUiOiJzdHVkaW8td29ya3Nob3AiLCJyb290RGlyZWN0b3J5IjpudWxsLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC9zdHVkaW8td29ya3Nob3AvOVpWc1UzaTkxRXRReldYS2JWZUR0Q0FaV011YiIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6InN0dWRpby13b3Jrc2hvcC1naXQtY2FjaGluZy1pc3N1ZXMtMi5zYW5pdHkuZGV2In0seyJuYW1lIjoicGVyZm9ybWFuY2Utc3R1ZGlvIiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3Nhbml0eS1zYW5kYm94L3BlcmZvcm1hbmNlLXN0dWRpby81aWtjelE2TkxWem43ZXBrall4NTVTeU5Nb29yIiwicHJldmlld1VybCI6InBlcmZvcm1hbmNlLXN0dWRpby1naXQtY2FjaGluZy1pc3N1ZXMtMi5zYW5pdHkuZGV2IiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InBlcmZvcm1hbmNlLXN0dWRpby1naXQtY2FjaGluZy1pc3N1ZXMtMi5zYW5pdHkuZGV2In19LHsibmFtZSI6InRlc3QtbmV4dC1zdHVkaW8iLCJyb290RGlyZWN0b3J5IjoiZGV2L3Rlc3QtbmV4dC1zdHVkaW8iLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC90ZXN0LW5leHQtc3R1ZGlvL0Noc1FlN1VDVXNhQXlRZHE4QjRDR3dESkhXMUoiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCJ9LHsibmFtZSI6InBhZ2UtYnVpbGRpbmctc3R1ZGlvIiwicm9vdERpcmVjdG9yeSI6ImRldi9wYWdlLWJ1aWxkaW5nLXN0dWRpbyIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC9wYWdlLWJ1aWxkaW5nLXN0dWRpby9CQndpWGl6YjRrTTNkUWlwd2tyU25Eb3dkcDhjIiwicHJldmlld1VybCI6InBhZ2UtYnVpbGRpbmctc3R1ZGlvLWdpdC1jYWNoaW5nLWlzc3Vlcy0yLnNhbml0eS5kZXYiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoicGFnZS1idWlsZGluZy1zdHVkaW8tZ2l0LWNhY2hpbmctaXNzdWVzLTIuc2FuaXR5LmRldiJ9fSx7Im5hbWUiOiJlMmUtc3R1ZGlvIiwicm9vdERpcmVjdG9yeSI6bnVsbCwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FuaXR5LXNhbmRib3gvZTJlLXN0dWRpby9BMUhUWWJHcFh4aFRjd1Rwd1JHYVJBTVRzU3ZMIiwibmV4dENvbW1pdFN0YXR1cyI6IklHTk9SRUQifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **page-building-studio** | ✅ Ready ([Inspect](https://vercel.com/sanity-sandbox/page-building-studio/BBwiXizb4kM3dQipwkrSnDowdp8c)) | [Visit Preview](https://page-building-studio-git-caching-issues-2.sanity.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/page-building-studio-git-caching-issues-2.sanity.dev?via=pr-comment-feedback-link) | May 23, 2025 7:39am |
| **performance-studio** | ✅ Ready ([Inspect](https://vercel.com/sanity-sandbox/performance-studio/5ikczQ6NLVzn7epkjYx55SyNMoor)) | [Visit Preview](https://performance-studio-git-caching-issues-2.sanity.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/performance-studio-git-caching-issues-2.sanity.dev?via=pr-comment-feedback-link) | May 23, 2025 7:39am |
| **test-studio** | ✅ Ready ([Inspect](https://vercel.com/sanity-sandbox/test-studio/HSwpFwZphxjRQnzqWsSqofSAh38w)) | [Visit Preview](https://test-studio-git-caching-issues-2.sanity.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/test-studio-git-caching-issues-2.sanity.dev?via=pr-comment-feedback-link) | May 23, 2025 7:39am |

<details><summary>3 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **e2e-studio** | ⬜️ Ignored ([Inspect](https://vercel.com/sanity-sandbox/e2e-studio/A1HTYbGpXxhTcwTpwRGaRAMTsSvL)) |  |  | May 23, 2025 7:39am |
| **studio-workshop** | ⬜️ Ignored ([Inspect](https://vercel.com/sanity-sandbox/studio-workshop/9ZVsU3i91EtQzWXKbVeDtCAZWMub)) | [Visit Preview](https://studio-workshop-git-caching-issues-2.sanity.dev) |  | May 23, 2025 7:39am |
| **test-next-studio** | ⬜️ Ignored ([Inspect](https://vercel.com/sanity-sandbox/test-next-studio/ChsQe7UCUsaAyQdq8B4CGwDJHW1J)) |  |  | May 23, 2025 7:39am |
</details>







author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T13:06:05Z
body: No changes to documentation
<!-- thollander/actions-comment-pull-request "docs-report" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-21T13:15:57Z
body: <h2>Coverage Report</h2>
<table> <thead> <tr> <th align="center">Status</th> <th align="left">Category</th> <th align="right">Percentage</th> <th align="right">Covered / Total</th> </tr> </thead> <tbody> <tr> <td align="center">🔵</td> <td align="left">Lines</td> <td align="right">43.4%</td> <td align="right">58413 / 134584</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Statements</td> <td align="right">43.4%</td> <td align="right">58413 / 134584</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Functions</td> <td align="right">48.14%</td> <td align="right">2925 / 6075</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Branches</td> <td align="right">79.48%</td> <td align="right">10852 / 13653</td> </tr> </tbody> </table><details><summary>File Coverage</summary><table> <thead> <tr> <th align="left">File</th> <th align="right">Stmts</th> <th align="right">Branches</th> <th align="right">Functions</th> <th align="right">Lines</th> <th align="left">Uncovered Lines</th> </tr> </thead> <tbody> <tr> <td align="left" colspan="6"><b>Changed Files</b></td> </tr> <tr> <td align="left"><a href="https://github.com/sanity-io/sanity/blob/a3c9126d8af611cc97165e4dab5fba839455471f/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx">packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx</a></td> <td align="right">97.05%</td> <td align="right">90%</td> <td align="right">100%</td> <td align="right">97.05%</td> <td align="left"><a href="https://github.com/sanity-io/sanity/blob/a3c9126d8af611cc97165e4dab5fba839455471f/packages/sanity/src/core/releases/hooks/useDocumentVersions.tsx#L94-L95" class="text-red">94-95</a></td> </tr> </tbody> </table></details>
<em>Generated in workflow <a href=https://github.com/sanity-io/sanity/actions/runs/15204886643>#35569</a> for commit <a href="https://github.com/sanity-io/sanity/commit/a3c9126d8af611cc97165e4dab5fba839455471f">a3c9126</a> by the <a href="https://github.com/davelosert/vitest-coverage-report-action">Vitest Coverage Report Action</a></em>

<!-- vitest-coverage-report-marker-root -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-21T13:20:44Z
body: ### 🧪 [E2E Preview environment](https://e2e-studio-pikiw5fuo.sanity.dev) 

<details>
<summary> 🔑 Environment Variables for Local Testing</summary>

This is the preview URL for the E2E tests: **https://e2e-studio-pikiw5fuo.sanity.dev**

To run the E2E tests locally, you can use the following environment variables, then run `pnpm test:e2e --ui` to open the Playwright test runner.

💬 Remember to build the project first with `pnpm build:e2e`.

```
  SANITY_E2E_PROJECT_ID=ittbm412
  SANITY_E2E_BASE_URL=https://e2e-studio-pikiw5fuo.sanity.dev
  SANITY_E2E_DATASET="update depending the project you want to test (pr-9471-chromium-15204886664 || pr-9471-firefox-15204886664 )"
  SANITY_E2E_DATASET_CHROMIUM=pr-9471-chromium-15204886664
  SANITY_E2E_DATASET_FIREFOX=pr-9471-firefox-15204886664
```

</details>
<!-- thollander/actions-comment-pull-request "e2e-preview-url" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-21T13:23:54Z
body: ### ⚡️ Editor Performance Report

Updated Fri, 23 May 2025 07:45:00 GMT


| Benchmark | reference<br/><sup>latency of `sanity@latest`</sup> | experiment<br/><sup>latency of this branch</sup> | Δ (%)<br/><sup>latency difference</sup> | |
| :-- | :-- | :-- | :-- | --- |
| article (title) | 23.3 efps (43ms) | 21.3 efps (47ms) | +4ms (+9.3%) | ✅ |
| article (body) | 25.8 efps (39ms) | 27.9 efps (36ms) | -3ms (-7.6%) | ✅ |
| article (string inside object) | 25.6 efps (39ms) | 23.3 efps (43ms) | +4ms (+10.3%) | ✅ |
| article (string inside array) | 22.2 efps (45ms) | 21.5 efps (47ms) | +2ms (+3.3%) | ✅ |
| recipe (name) | 47.6 efps (21ms) | 47.6 efps (21ms) | +0ms (-/-%) | ✅ |
| recipe (description) | 52.6 efps (19ms) | 55.6 efps (18ms) | -1ms (-5.3%) | ✅ |
| recipe (instructions) | 99.9+ efps (10ms) | 99.9+ efps (9ms) | -1ms (-/-%) | ✅ |
| synthetic (title) | 17.9 efps (56ms) | 17.5 efps (57ms) | +1ms (+1.8%) | ✅ |
| synthetic (string inside object) | 17.5 efps (57ms) | 17.9 efps (56ms) | -1ms (-1.8%) | ✅ |


> **efps** — editor "frames per second". The number of updates assumed to be possible within a second.
>
> Derived from input latency. `efps = 1000 / input_latency`

<details>

<summary><strong>Detailed information</strong></summary>

### 🏠 Reference result

The performance result of `sanity@latest`



| Benchmark | latency | p75 | p90 | p99 | blocking time | test duration |
| --------- | ------: | --: | --: | --: | ------------: | ------------: |
| article (title) | 43ms | 48ms | 54ms | 62ms | 187ms | 10.8s |
| article (body) | 39ms | 57ms | 89ms | 499ms | 1247ms | 11.4s |
| article (string inside object) | 39ms | 41ms | 55ms | 277ms | 394ms | 7.6s |
| article (string inside array) | 45ms | 49ms | 57ms | 264ms | 488ms | 8.0s |
| recipe (name) | 21ms | 23ms | 28ms | 66ms | 0ms | 7.4s |
| recipe (description) | 19ms | 21ms | 24ms | 38ms | 0ms | 4.9s |
| recipe (instructions) | 10ms | 13ms | 24ms | 45ms | 0ms | 4.9s |
| synthetic (title) | 56ms | 59ms | 64ms | 319ms | 1299ms | 15.3s |
| synthetic (string inside object) | 57ms | 59ms | 68ms | 256ms | 1025ms | 8.9s |


### 🧪 Experiment result

The performance result of this branch


| Benchmark | latency | p75 | p90 | p99 | blocking time | test duration |
| --------- | ------: | --: | --: | --: | ------------: | ------------: |
| article (title) | 47ms | 53ms | 60ms | 86ms | 307ms | 12.0s |
| article (body) | 36ms | 47ms | 87ms | 280ms | 1017ms | 10.7s |
| article (string inside object) | 43ms | 47ms | 54ms | 82ms | 289ms | 7.7s |
| article (string inside array) | 47ms | 51ms | 62ms | 264ms | 599ms | 8.2s |
| recipe (name) | 21ms | 24ms | 26ms | 52ms | 0ms | 7.3s |
| recipe (description) | 18ms | 20ms | 21ms | 37ms | 0ms | 4.6s |
| recipe (instructions) | 9ms | 11ms | 15ms | 48ms | 0ms | 4.7s |
| synthetic (title) | 57ms | 59ms | 72ms | 231ms | 978ms | 14.1s |
| synthetic (string inside object) | 56ms | 61ms | 71ms | 217ms | 971ms | 8.8s |


### 📚 Glossary

> #### column definitions
>
> - **benchmark** — the name of the test, e.g. "article", followed by the label of the field being measured, e.g. "(title)".
> - **latency** — the time between when a key was pressed and when it was rendered. derived from a set of samples. the median (p50) is shown to show the most common latency.
> - **p75** — the 75th percentile of the input latency in the test run. 75% of the sampled inputs in this benchmark were processed faster than this value. this provides insight into the upper range of typical performance.
> - **p90** — the 90th percentile of the input latency in the test run. 90% of the sampled inputs were faster than this. this metric helps identify slower interactions that occurred less frequently during the benchmark.
> - **p99** — the 99th percentile of the input latency in the test run. only 1% of sampled inputs were slower than this. this represents the worst-case scenarios encountered during the benchmark, useful for identifying potential performance outliers.
> - **blocking time** — the total time during which the main thread was blocked, preventing user input and UI updates. this metric helps identify performance bottlenecks that may cause the interface to feel unresponsive.
> - **test duration** — how long the test run took to complete.

</details>

<!-- thollander/actions-comment-pull-request "efps-report" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-21T13:24:49Z
body: ### 📊 Playwright Test Report

**[Download Full E2E Report](https://github.com/sanity-io/sanity/actions/runs/15204886664/artifacts/3183275589)**

This report contains test results, including videos of failing tests.
<!-- thollander/actions-comment-pull-request "playwright-e2e-report" -->
author_association: CONTRIBUTOR