Repository: sanity-io/sanity
Pull request #9489: fix(core): add condition in useDocumentTitle to allow for unpublished preview title
Author: login: RitaDias
Created: 2025-05-23T13:22:21Z | Merged: 2025-05-30T10:28:13Z

### Description

Fixes issue where the preview of a document that is slated for unpublished shows the wrong preview

Before
![image](https://github.com/user-attachments/assets/37d183e9-e6b1-4c1e-bc62-7bd98a251062)

After
<img width="821" alt="image" src="https://github.com/user-attachments/assets/2217df42-5050-4ecc-b176-7ad6108829c4" />

### What to review

Check the code

### Testing

Automated test should suffice.
Otherwise, put a version to unpublish and double check that the document title appears as expected

Or you can go to [this document](https://test-studio-git-sapp-2635-follow-up.sanity.dev/test/structure/species;cb97aa56-63db-43f5-ae84-809d8ec93d5f?perspective=rooVsdzKz) 

### Notes for release

The document displayed of a version that will be unpublished within a release will show the right title


Diff:
diff --git a/packages/sanity/src/core/preview/useValuePreview.ts b/packages/sanity/src/core/preview/useValuePreview.ts
index c69c3fb1ab04..e4a0809804cf 100644
--- a/packages/sanity/src/core/preview/useValuePreview.ts
+++ b/packages/sanity/src/core/preview/useValuePreview.ts
@@ -1,10 +1,16 @@
-import {type PreviewValue, type SchemaType, type SortOrdering} from '@sanity/types'
+import {
+  type PreviewValue,
+  type SanityDocument,
+  type SchemaType,
+  type SortOrdering,
+} from '@sanity/types'
 import {useMemo} from 'react'
 import {useObservable} from 'react-rx'
 import {type Observable, of} from 'rxjs'
 import {catchError, map} from 'rxjs/operators'
 
 import {usePerspective} from '../perspective/usePerspective'
+import {isGoingToUnpublish} from '../releases/util/isGoingToUnpublish'
 import {useDocumentPreviewStore} from '../store'
 import {type Previewable} from './types'
 
@@ -43,8 +49,15 @@ function useDocumentPreview(props: {
     // this will render previews as "loaded" (i.e. not in loading state) – typically with "Untitled" text
     if (!enabled || !previewValue || !schemaType) return of(IDLE_STATE)
 
+    const updatedStack =
+      // when a version is slated for unpublishing then we need to remove the version from the stack
+      // and use the next one down
+      perspectiveStack.length > 1 && isGoingToUnpublish(previewValue as SanityDocument)
+        ? perspectiveStack.slice(1)
+        : perspectiveStack
+
     return observeForPreview(previewValue as Previewable, schemaType, {
-      perspective: perspectiveStack,
+      perspective: updatedStack,
       viewOptions: {ordering: ordering},
     }).pipe(
       map((event) => ({isLoading: false, value: event.snapshot || undefined})),
diff --git a/test/e2e/tests/releases/displayDocument/DisplayedDocument.spec.ts b/test/e2e/tests/releases/displayDocument/DisplayedDocument.spec.ts
index 0494975e8907..e907fe70bea2 100644
--- a/test/e2e/tests/releases/displayDocument/DisplayedDocument.spec.ts
+++ b/test/e2e/tests/releases/displayDocument/DisplayedDocument.spec.ts
@@ -444,5 +444,54 @@ test.describe('displayedDocument', () => {
 
       archiveAndDeleteRelease({sanityClient, dataset, releaseId: scheduledId})
     })
+
+    test('no draft, no publish, one version with _system.delete shows version name', async ({
+      page,
+      sanityClient,
+      _testContext,
+      browserName,
+    }) => {
+      test.slow()
+      skipIfBrowser(browserName)
+
+      const documentId = _testContext.getUniqueDocumentId()
+      const versionId = `versions.${asapReleaseId}.${documentId}`
+
+      const customPublished = await createDocument(sanityClient, {
+        ...speciesDocumentNamePublished,
+        _id: documentId,
+      })
+
+      // Create a document with a version that has _system.delete set to true
+      await createDocument(sanityClient, {
+        ...speciesDocumentNameASAP,
+        _id: versionId,
+        _system: {
+          delete: true,
+        },
+      })
+
+      await page.goto(`/content/species;${documentId}?perspective=${asapReleaseId}`)
+
+      // Wait for document to load
+      await expect(page.getByTestId('document-header-Draft-chip')).toBeVisible()
+      const asapChip = page.getByTestId('document-header-ASAP-Release-A-chip')
+      await expect(asapChip).toBeVisible()
+      await expect(page.getByTestId('field-name').getByTestId('string-input')).toBeVisible()
+
+      // Check that the version chip is selected
+      await expect(page.getByTestId('document-header-Draft-chip')).not.toHaveAttribute(
+        'data-selected',
+      )
+      await expect(asapChip).toHaveAttribute('data-selected')
+
+      await expect(page.getByTestId('document-panel-document-title')).not.toHaveText('Untitled')
+      // Check that the name field shows the version name
+      await expect(page.getByTestId('document-panel-document-title')).toHaveText('(ASAP A)')
+
+      // Clean up
+      await sanityClient.delete(versionId)
+      await sanityClient.delete(customPublished._id)
+    })
   })
 })


Reviews:
user: login: RitaDias
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T13:28:44Z
user: login: RitaDias
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T06:38:01Z
user: login: RitaDias
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T06:57:08Z
user: login: pedrobonamin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T07:51:59Z
user: login: RitaDias
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T08:07:42Z
user: login: RitaDias
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T08:15:00Z
user: login: pedrobonamin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T08:27:10Z
user: login: RitaDias
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T08:30:13Z
user: login: jordanl17
body: Nice fix!
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-29T15:16:34Z
user: login: pedrobonamin
body: Looks good to me, it's nice to have this fixed in all the uses of the document preview. Thank you! 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T10:27:31Z

Review comments:
diff_hunk: @@ -50,6 +51,15 @@ export function useDocumentTitle(): UseDocumentTitle {
     }
   }
 
+  // in cases where the displayed document has been slated to be unpublished
+  // then the displayed title should be the name of the document
+  if ('_system' in displayed && (displayed as Partial<SanityDocument>)._system?.delete) {
path: packages/sanity/src/structure/panes/document/useDocumentTitle.ts
user: login: RitaDias
body: I considered using the condition `isGoingToUnpublish(displayed as SanityDocumentLike)` (see [here](https://github.com/sanity-io/sanity/blob/sapp-2635-follow-up/packages/sanity/src/core/releases/util/isGoingToUnpublish.ts#L11)) but this felt safer to do it this way since the implementation woudl have had to be with a few more castings that I don't quite undestand _why_ it's needed except the code editor screams otherwise

```suggestion
  if (isGoingToUnpublish(displayed as SanityDocument)) {
    return {
      error: undefined,
      title: (displayed as SanityDocument).name as string,
    }
  }
```
    
created_at: 2025-05-23T13:28:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,6 +55,15 @@ export function useDocumentTitle(): UseDocumentTitle {
     }
   }
 
+  // in cases where the displayed document has been slated to be unpublished
+  // then the displayed title should be the name of the document
+  if (isGoingToUnpublish(displayed as SanityDocument)) {
path: packages/sanity/src/structure/panes/document/useDocumentTitle.ts
user: login: RitaDias
body: My initial approach was to use the condition `if ('_system' in displayed && (displayed as Partial<SanityDocument>)._system?.delete))` but this felt better since it's a condition we use in other places to check the same use case. Let me know if you have a strong preference of one over the other

```suggestion
  if ('_system' in displayed && (displayed as SanityDocument)._system?.delete) {
```
    
created_at: 2025-05-26T06:38:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,6 +20,11 @@ export function DocumentHeaderTitle(): React.JSX.Element {
     return <></>
   }
 
+  // if there is an error then show error message first and don't try to render the title
+  if (error) {
+    return <>{t('panes.document-header-title.error.text', {error: error.message})}</>
+  }
+
path: packages/sanity/src/structure/panes/document/documentPanel/header/DocumentHeaderTitle.tsx
user: login: RitaDias
body: had to move the logic since where it was before with using the documentPane hook it was relying on showing "untitled" for a title that they could not find. Leaving the logic as was, would fail the test when an error is sent via the useValuePreview
created_at: 2025-05-26T06:57:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,6 +55,15 @@ export function useDocumentTitle(): UseDocumentTitle {
     }
   }
 
+  // in cases where the displayed document has been slated to be unpublished
+  // then the displayed title should be the name of the document
+  if (isGoingToUnpublish(displayed as SanityDocument)) {
path: packages/sanity/src/structure/panes/document/useDocumentTitle.ts
user: login: pedrobonamin
body: What happens if the document doesn't have a name property? Name is a property that could exist or not in the context of the document, right?

What if instead of doing the check here, at the return value, we do the check above before the `useValuePreview` invocation and we change the document that is being passed.

Example: 
```ts
 const documentValue = useMemo(() => isGoingtoUnpublish(displayed) ? 
     editState.published : displayed
  , [editState.published, displayed ])

  const {error, value} = useValuePreview({
    enabled: subscribed,
    schemaType,
    value: documentValue,
  })

```

That I think will give you the title of the published document.
Maybe we also need to update the perspectiveStack that the `useValuePreview` function is using.

For example [this document](https://test-studio-git-sapp-2635-follow-up.sanity.dev/test/structure/input-standard;portable-text;pt_allTheBellsAndWhistles;aea9bebc-41ab-4e10-8f3a-d97b8187b688%2Ctemplate%3Dpt_allTheBellsAndWhistles?perspective=rayiSgMXw)

created_at: 2025-05-26T07:51:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,6 +55,15 @@ export function useDocumentTitle(): UseDocumentTitle {
     }
   }
 
+  // in cases where the displayed document has been slated to be unpublished
+  // then the displayed title should be the name of the document
+  if (isGoingToUnpublish(displayed as SanityDocument)) {
path: packages/sanity/src/structure/panes/document/useDocumentTitle.ts
user: login: RitaDias
body: - I don't think it makes sense (when the document is slated for unpublishing) that we show the title of the "published", shouldn't it still show the information for the version that is being displayed?
- It's not _always_ the published doc (for example in situations where the release will have the document published before in the perspective stack, the way we handle it in other places is by showing what the document will look like with the stack before this one that you are trying to unpublish). We would need to go through the stack and see what document it actually is (and that would not be available through the editState here).

This feels like it touches on a larger problem / question I guess that is not necessarily related to the titles but to the document displayed:
When a document is slated for unpublishing, the displayed document is _not_ the document that will come next on the stack (on the example video, Kayla's version). It will be the version of the release itself, for example: if I created a version of a document and edited it, and _then_ click to unpublish -> the displayed version will still be that version _not_ whatever comes next in perspective stack. We could make this change, but I'm not sure if this won't add confusion :/ 

Hopefully the video and notes explain what I mean:

https://github.com/user-attachments/assets/249125e4-7ef6-4584-b2e8-28aa5ab13689



As far as I know a document will _always_ have name. The title however, is optional, but I don't think that's necessarily a bad thing since we do have fall backs for when the title is undefined where it shows the name "untitled" but those should still work as expected 
created_at: 2025-05-26T08:07:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,6 +55,15 @@ export function useDocumentTitle(): UseDocumentTitle {
     }
   }
 
+  // in cases where the displayed document has been slated to be unpublished
+  // then the displayed title should be the name of the document
+  if (isGoingToUnpublish(displayed as SanityDocument)) {
path: packages/sanity/src/structure/panes/document/useDocumentTitle.ts
user: login: RitaDias
body: Or maybe it does make sense to link the whole displayed document to the previous stack version? The more I think about this the less sure i feel 😂 but in which case then the link from the releases detail should also link to it instead of the release document version with the banner. But again, I'm not sure if that just won't add to the confusion tbh

Or maybe it's about updating also the text that is on the banner
created_at: 2025-05-26T08:15:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,6 +55,15 @@ export function useDocumentTitle(): UseDocumentTitle {
     }
   }
 
+  // in cases where the displayed document has been slated to be unpublished
+  // then the displayed title should be the name of the document
+  if (isGoingToUnpublish(displayed as SanityDocument)) {
path: packages/sanity/src/structure/panes/document/useDocumentTitle.ts
user: login: pedrobonamin
body: Not all documents will have a `name` because that will depend on how the document schema is defined, look for example [this document](https://test-studio-git-sapp-2635-follow-up.sanity.dev/test/structure/input-standard;portable-text;pt_allTheBellsAndWhistles;aea9bebc-41ab-4e10-8f3a-d97b8187b688%2Ctemplate%3Dpt_allTheBellsAndWhistles?perspective=rayiSgMXw)

So that won't solve all the document types, users could define docs without name.
Maybe you are thinking on the schemas? The document schema always needs to have a name.
But to have a name on the document, you need to define the field: name

```
 export const noNameInDoc = defineType({
   name: "noName",
  type: "document",
  fields: [{
     name: "foo",
     type: "string",
  }]
})

 export const nameInDoc = defineType({
   name: "withName",
  type: "document",
  fields: [{
     name: "name",
     type: "string",
  }]
})
```

I think it should display the same value we are displaying in the release detail and we are using the `published` value there? Or is it the last one from the stack?
created_at: 2025-05-26T08:27:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,6 +55,15 @@ export function useDocumentTitle(): UseDocumentTitle {
     }
   }
 
+  // in cases where the displayed document has been slated to be unpublished
+  // then the displayed title should be the name of the document
+  if (isGoingToUnpublish(displayed as SanityDocument)) {
path: packages/sanity/src/structure/panes/document/useDocumentTitle.ts
user: login: RitaDias
body: Ah I understand what you mean!

It's the last one from the stack 🤔 
Alright then I think maybe the way to go is to go is to actually display the previous value (in the video then it would show kayla's version) and update the description of the banner to say: this is the document that will be unpublished to given the order of releases prior to this one. 
created_at: 2025-05-26T08:30:13Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-23T13:22:23Z
body: [vc]: #Je7EoV+WxTyZOukbprCzL8gY752LIft5hbTnl33ecWs=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJwZXJmb3JtYW5jZS1zdHVkaW8iLCJyb290RGlyZWN0b3J5IjpudWxsLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FuaXR5LXNhbmRib3gvcGVyZm9ybWFuY2Utc3R1ZGlvLzJmUVcyR29NRXZ3RHA2YnhxZUxTbll2QzQydmUiLCJwcmV2aWV3VXJsIjoicGVyZm9ybWFuY2Utc3R1ZGlvLWdpdC1zYXBwLTI2MzUtZm9sbG93LXVwLnNhbml0eS5kZXYiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoicGVyZm9ybWFuY2Utc3R1ZGlvLWdpdC1zYXBwLTI2MzUtZm9sbG93LXVwLnNhbml0eS5kZXYifX0seyJuYW1lIjoidGVzdC1zdHVkaW8iLCJyb290RGlyZWN0b3J5IjpudWxsLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoidGVzdC1zdHVkaW8tZ2l0LXNhcHAtMjYzNS1mb2xsb3ctdXAuc2FuaXR5LmRldiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FuaXR5LXNhbmRib3gvdGVzdC1zdHVkaW8vNmo4Smo2ZXQ2TGY0U3lBOEJUR0dNbVdhdnBxdyIsInByZXZpZXdVcmwiOiJ0ZXN0LXN0dWRpby1naXQtc2FwcC0yNjM1LWZvbGxvdy11cC5zYW5pdHkuZGV2IiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn0seyJuYW1lIjoic3R1ZGlvLXdvcmtzaG9wIiwicm9vdERpcmVjdG9yeSI6bnVsbCwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FuaXR5LXNhbmRib3gvc3R1ZGlvLXdvcmtzaG9wLzZrek13VFlMSGVCMk5HYkw1MXlhcEt1ajZ5N3UiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsInByZXZpZXdVcmwiOiJzdHVkaW8td29ya3Nob3AtZ2l0LXNhcHAtMjYzNS1mb2xsb3ctdXAuc2FuaXR5LmRldiJ9LHsibmFtZSI6InRlc3QtbmV4dC1zdHVkaW8iLCJyb290RGlyZWN0b3J5IjoiZGV2L3Rlc3QtbmV4dC1zdHVkaW8iLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC90ZXN0LW5leHQtc3R1ZGlvL0hhQ0UyZjdvZmpyb3NSQ1BEelhtcEY5WDFpSHoiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCJ9LHsibmFtZSI6InBhZ2UtYnVpbGRpbmctc3R1ZGlvIiwicm9vdERpcmVjdG9yeSI6ImRldi9wYWdlLWJ1aWxkaW5nLXN0dWRpbyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJwYWdlLWJ1aWxkaW5nLXN0dWRpby1naXQtc2FwcC0yNjM1LWZvbGxvdy11cC5zYW5pdHkuZGV2In0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYW5pdHktc2FuZGJveC9wYWdlLWJ1aWxkaW5nLXN0dWRpby82Vll0S0FraWh5TXR1OGlURjlZWFJ3QlhYTnZHIiwicHJldmlld1VybCI6InBhZ2UtYnVpbGRpbmctc3R1ZGlvLWdpdC1zYXBwLTI2MzUtZm9sbG93LXVwLnNhbml0eS5kZXYiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQifSx7Im5hbWUiOiJlMmUtc3R1ZGlvIiwicm9vdERpcmVjdG9yeSI6bnVsbCwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FuaXR5LXNhbmRib3gvZTJlLXN0dWRpby80Y3ZFQXJwTUdLTGtYNW11NGNidnRHaHZud3dQIiwibmV4dENvbW1pdFN0YXR1cyI6IklHTk9SRUQifV19
**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/6VYtKAkihyMtu8iTF9YXRwBXXNvG)) | [Visit Preview](https://page-building-studio-git-sapp-2635-follow-up.sanity.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/page-building-studio-git-sapp-2635-follow-up.sanity.dev?via=pr-comment-feedback-link) | May 30, 2025 8:14am |
| **performance-studio** | ✅ Ready ([Inspect](https://vercel.com/sanity-sandbox/performance-studio/2fQW2GoMEvwDp6bxqeLSnYvC42ve)) | [Visit Preview](https://performance-studio-git-sapp-2635-follow-up.sanity.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/performance-studio-git-sapp-2635-follow-up.sanity.dev?via=pr-comment-feedback-link) | May 30, 2025 8:14am |
| **test-studio** | ✅ Ready ([Inspect](https://vercel.com/sanity-sandbox/test-studio/6j8Jj6et6Lf4SyA8BTGGMmWavpqw)) | [Visit Preview](https://test-studio-git-sapp-2635-follow-up.sanity.dev) | 💬 [**Add feedback**](https://vercel.live/open-feedback/test-studio-git-sapp-2635-follow-up.sanity.dev?via=pr-comment-feedback-link) | May 30, 2025 8:14am |

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

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **e2e-studio** | ⬜️ Ignored ([Inspect](https://vercel.com/sanity-sandbox/e2e-studio/4cvEArpMGKLkX5mu4cbvtGhvnwwP)) |  |  | May 30, 2025 8:14am |
| **studio-workshop** | ⬜️ Ignored ([Inspect](https://vercel.com/sanity-sandbox/studio-workshop/6kzMwTYLHeB2NGbL51yapKuj6y7u)) | [Visit Preview](https://studio-workshop-git-sapp-2635-follow-up.sanity.dev) |  | May 30, 2025 8:14am |
| **test-next-studio** | ⬜️ Ignored ([Inspect](https://vercel.com/sanity-sandbox/test-next-studio/HaCE2f7ofjrosRCPDzXmpF9X1iHz)) |  |  | May 30, 2025 8:14am |
</details>







author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-23T13:27:04Z
body: No changes to documentation
<!-- thollander/actions-comment-pull-request "docs-report" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-23T13:28:28Z
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.43%</td> <td align="right">58732 / 135210</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Statements</td> <td align="right">43.43%</td> <td align="right">58732 / 135210</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Functions</td> <td align="right">48.17%</td> <td align="right">2937 / 6096</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Branches</td> <td align="right">79.3%</td> <td align="right">10838 / 13666</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/bfb3e390da7a75d4752909c47c54e80e57f91c59/packages/sanity/src/core/preview/useValuePreview.ts">packages/sanity/src/core/preview/useValuePreview.ts</a></td> <td align="right">94.11%</td> <td align="right">58.33%</td> <td align="right">66.66%</td> <td align="right">94.11%</td> <td align="left"><a href="https://github.com/sanity-io/sanity/blob/bfb3e390da7a75d4752909c47c54e80e57f91c59/packages/sanity/src/core/preview/useValuePreview.ts#L63-L64" class="text-red">63-64</a></td> </tr> </tbody> </table></details>
<em>Generated in workflow <a href=https://github.com/sanity-io/sanity/actions/runs/15342365609>#35868</a> for commit <a href="https://github.com/sanity-io/sanity/commit/bfb3e390da7a75d4752909c47c54e80e57f91c59">bfb3e39</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-23T13:31:13Z
body: ### 🧪 [E2E Preview environment](https://e2e-studio-px125zv46.sanity.dev) 

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

This is the preview URL for the E2E tests: **https://e2e-studio-px125zv46.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-px125zv46.sanity.dev
  SANITY_E2E_DATASET="update depending the project you want to test (pr-9489-chromium-15342365624 || pr-9489-firefox-15342365624 )"
  SANITY_E2E_DATASET_CHROMIUM=pr-9489-chromium-15342365624
  SANITY_E2E_DATASET_FIREFOX=pr-9489-firefox-15342365624
```

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

Updated Fri, 30 May 2025 09:35:10 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) | 25.0 efps (40ms) | 22.0 efps (46ms) | +6ms (+13.8%) | ✅ |
| article (body) | 29.6 efps (34ms) | 32.5 efps (31ms) | -3ms (-8.9%) | ✅ |
| article (string inside object) | 28.2 efps (36ms) | 25.6 efps (39ms) | +4ms (+9.9%) | ✅ |
| article (string inside array) | 23.8 efps (42ms) | 22.2 efps (45ms) | +3ms (+7.1%) | ✅ |
| recipe (name) | 50.0 efps (20ms) | 50.0 efps (20ms) | +0ms (-/-%) | ✅ |
| recipe (description) | 55.6 efps (18ms) | 55.6 efps (18ms) | +0ms (-/-%) | ✅ |
| recipe (instructions) | 99.9+ efps (8ms) | 99.9+ efps (8ms) | +0ms (-/-%) | ✅ |
| synthetic (title) | 18.2 efps (55ms) | 18.2 efps (55ms) | +0ms (-/-%) | ✅ |
| synthetic (string inside object) | 18.3 efps (55ms) | 18.2 efps (55ms) | +1ms (+0.9%) | ✅ |


> **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) | 40ms | 48ms | 56ms | 290ms | 347ms | 11.8s |
| article (body) | 34ms | 42ms | 62ms | 278ms | 798ms | 10.5s |
| article (string inside object) | 36ms | 40ms | 46ms | 265ms | 317ms | 7.1s |
| article (string inside array) | 42ms | 44ms | 49ms | 193ms | 256ms | 7.1s |
| recipe (name) | 20ms | 22ms | 25ms | 46ms | 0ms | 7.8s |
| recipe (description) | 18ms | 20ms | 27ms | 51ms | 0ms | 4.7s |
| recipe (instructions) | 8ms | 11ms | 13ms | 15ms | 0ms | 4.5s |
| synthetic (title) | 55ms | 57ms | 64ms | 252ms | 1056ms | 14.5s |
| synthetic (string inside object) | 55ms | 59ms | 70ms | 345ms | 1170ms | 8.5s |


### 🧪 Experiment result

The performance result of this branch


| Benchmark | latency | p75 | p90 | p99 | blocking time | test duration |
| --------- | ------: | --: | --: | --: | ------------: | ------------: |
| article (title) | 46ms | 50ms | 82ms | 245ms | 285ms | 11.9s |
| article (body) | 31ms | 47ms | 76ms | 457ms | 954ms | 10.4s |
| article (string inside object) | 39ms | 44ms | 50ms | 211ms | 353ms | 7.4s |
| article (string inside array) | 45ms | 48ms | 58ms | 234ms | 319ms | 7.7s |
| recipe (name) | 20ms | 21ms | 26ms | 56ms | 0ms | 7.7s |
| recipe (description) | 18ms | 19ms | 21ms | 34ms | 0ms | 4.6s |
| recipe (instructions) | 8ms | 10ms | 12ms | 44ms | 0ms | 4.7s |
| synthetic (title) | 55ms | 58ms | 66ms | 97ms | 626ms | 15.6s |
| synthetic (string inside object) | 55ms | 58ms | 67ms | 422ms | 1475ms | 9.1s |


### 📚 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-23T13:35:43Z
body: ### 📊 Playwright Test Report

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

This report contains test results, including videos of failing tests.
<!-- thollander/actions-comment-pull-request "playwright-e2e-report" -->
author_association: CONTRIBUTOR
user: login: RitaDias
created_at: 2025-05-30T07:51:43Z
body: > Nice fix!

Hi Jordan!
Thank you for reviewing this!

I unfortunately might need to scrap this whole solution. While working on [this](https://github.com/sanity-io/sanity/pull/9489#discussion_r2106830041) suggestion (which sprouted a different PR just so I could keep things separate) I realised that my solution for this particular part (and observers) doesn't work the same way and has edge cases that this code doesn't account for.
This particular edge case, for example, is when you have two releases that are slated for unpublishing. The second one (the one that is meant to happen later) will still have the untitled (this also happens in the release detail if you see). You can see this use case in [this document](https://test-studio-git-sapp-2635-follow-up.sanity.dev/test/structure/species;cb97aa56-63db-43f5-ae84-809d8ec93d5f?perspective=rQWCalaw7)

I'm going to bring it up during stand up, I think that the suggestion is worth it but there are some complications that I want to spend more time on. However, I think it's worth pushing this (even with edge cases, which already exist in prod) so that it at least doesn't look broken on first pass.

Edit: spoke during standup, let's get this out
author_association: CONTRIBUTOR