From 691aad1ee71480f7b70854b3a579741977c9efa0 Mon Sep 17 00:00:00 2001
From: Cody Olsen <81981+stipsan@users.noreply.github.com>
Date: Thu, 23 Jan 2025 00:31:31 +0100
Subject: [PATCH] fix(presentation): support `pt::text` in live queries (#8380)

---
 .../preview/SimpleBlockPortableText.tsx       |  4 ++-
 packages/sanity/package.json                  |  1 +
 .../src/presentation/loader/LiveQueries.tsx   | 13 ++------
 .../src/presentation/loader/LoaderQueries.tsx | 13 ++------
 .../sanity/src/presentation/loader/utils.ts   | 33 +++++++++++++++++++
 pnpm-lock.yaml                                |  3 ++
 6 files changed, 44 insertions(+), 23 deletions(-)

diff --git a/dev/test-studio/preview/SimpleBlockPortableText.tsx b/dev/test-studio/preview/SimpleBlockPortableText.tsx
index 4cd27549f65..2bb43aea409 100644
--- a/dev/test-studio/preview/SimpleBlockPortableText.tsx
+++ b/dev/test-studio/preview/SimpleBlockPortableText.tsx
@@ -24,10 +24,11 @@ export function SimpleBlockPortableText(): React.JSX.Element {
     {
       _id: string
       title: string | null
+      bodyString: string
       body: PortableTextBlock[]
       notes: {_key: string; title?: string; minutes?: number; notes?: PortableTextBlock[]}[]
     }[]
-  >(/* groq */ `*[_type == "simpleBlock"]{_id,title,body,notes}`)
+  >(/* groq */ `*[_type == "simpleBlock"]{_id,title,"bodyString":pt::text(body),body,notes}`)
 
   if (error) {
     throw error
@@ -52,6 +53,7 @@ export function SimpleBlockPortableText(): React.JSX.Element {
             }}
           >
             <h1>{item.title || 'Untitled'}</h1>
+            <p>{item.bodyString}</p>
             <PortableText components={components} value={item.body} />
             {item.notes?.map((note) => (
               <div key={note._key}>
diff --git a/packages/sanity/package.json b/packages/sanity/package.json
index 7fccc22dc30..eb5663be129 100644
--- a/packages/sanity/package.json
+++ b/packages/sanity/package.json
@@ -156,6 +156,7 @@
     "@portabletext/block-tools": "^1.1.2",
     "@portabletext/editor": "^1.25.0",
     "@portabletext/react": "^3.0.0",
+    "@portabletext/toolkit": "^2.0.16",
     "@rexxars/react-json-inspector": "^9.0.1",
     "@sanity/asset-utils": "^2.0.6",
     "@sanity/bifur-client": "^0.4.1",
diff --git a/packages/sanity/src/presentation/loader/LiveQueries.tsx b/packages/sanity/src/presentation/loader/LiveQueries.tsx
index e94e872a0ae..60e1025442c 100644
--- a/packages/sanity/src/presentation/loader/LiveQueries.tsx
+++ b/packages/sanity/src/presentation/loader/LiveQueries.tsx
@@ -41,7 +41,7 @@ import {
   type PresentationPerspective,
 } from '../types'
 import {type DocumentOnPage} from '../useDocumentsOnPage'
-import {useQueryParams, useRevalidate} from './utils'
+import {mapChangedValue, useQueryParams, useRevalidate} from './utils'
 
 export interface LoaderQueriesProps {
   liveDocument: Partial<SanityDocument> | null | undefined
@@ -197,8 +197,6 @@ export default function LoaderQueries(props: LoaderQueriesProps): React.JSX.Elem
     if (comlink) {
       // eslint-disable-next-line @typescript-eslint/no-shadow
       const {projectId, dataset} = clientConfig
-      // @todo - Can this be migrated/deprecated in favour of emitting
-      // `presentation/perspective` at a higher level?
       comlink.post('loader/perspective', {
         projectId: projectId!,
         dataset: dataset!,
@@ -482,14 +480,7 @@ export function turboChargeResultIfSourceMap<T = unknown>(
       }
       return null
     },
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    (changedValue: any, {previousValue}) => {
-      if (typeof changedValue === 'number' && typeof previousValue === 'string') {
-        // If the string() function was used in the query, we need to convert the source value to a string as well
-        return `${changedValue}`
-      }
-      return changedValue
-    },
+    mapChangedValue,
     perspective,
   )
 }
diff --git a/packages/sanity/src/presentation/loader/LoaderQueries.tsx b/packages/sanity/src/presentation/loader/LoaderQueries.tsx
index 54a641f2859..b02de31950e 100644
--- a/packages/sanity/src/presentation/loader/LoaderQueries.tsx
+++ b/packages/sanity/src/presentation/loader/LoaderQueries.tsx
@@ -41,7 +41,7 @@ import {
   type PresentationPerspective,
 } from '../types'
 import {type DocumentOnPage} from '../useDocumentsOnPage'
-import {useQueryParams, useRevalidate} from './utils'
+import {mapChangedValue, useQueryParams, useRevalidate} from './utils'
 
 export interface LoaderQueriesProps {
   liveDocument: Partial<SanityDocument> | null | undefined
@@ -174,8 +174,6 @@ export default function LoaderQueries(props: LoaderQueriesProps): React.JSX.Elem
     if (comlink) {
       // eslint-disable-next-line @typescript-eslint/no-shadow
       const {projectId, dataset} = clientConfig
-      // @todo - Can this be migrated/deprecated in favour of emitting
-      // `presentation/perspective` at a higher level?
       comlink.post('loader/perspective', {
         projectId: projectId!,
         dataset: dataset!,
@@ -556,14 +554,7 @@ export function turboChargeResultIfSourceMap<T = unknown>(
       // Fallback to general documents cache
       return cache.get(sourceDocument._id)
     },
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    (changedValue: any, {previousValue}) => {
-      if (typeof changedValue === 'number' && typeof previousValue === 'string') {
-        // If the string() function was used in the query, we need to convert the source value to a string as well
-        return `${changedValue}`
-      }
-      return changedValue
-    },
+    mapChangedValue,
     perspective,
   )
 }
diff --git a/packages/sanity/src/presentation/loader/utils.ts b/packages/sanity/src/presentation/loader/utils.ts
index 1092203b48d..6112d316332 100644
--- a/packages/sanity/src/presentation/loader/utils.ts
+++ b/packages/sanity/src/presentation/loader/utils.ts
@@ -1,5 +1,38 @@
+import {toPlainText} from '@portabletext/react'
+import {isPortableTextBlock} from '@portabletext/toolkit'
 import {type ClientPerspective, type QueryParams} from '@sanity/client'
+import {type ApplySourceDocumentsUpdateFunction} from '@sanity/client/csm'
 import {useCallback, useEffect, useMemo, useState, useSyncExternalStore} from 'react'
+import {type FIXME} from 'sanity'
+
+/**
+ * Used by `applySourceDocuments`
+ * @internal
+ */
+export const mapChangedValue: ApplySourceDocumentsUpdateFunction = (
+  changedValue: FIXME,
+  {previousValue},
+) => {
+  if (typeof previousValue === 'string') {
+    if (typeof changedValue === 'number') {
+      // If the string() function was used in the query, we need to convert the source value to a string as well
+      return `${changedValue}`
+    }
+    // If it's an array in the source, but a string in the query response, it could be pt::text
+    if (Array.isArray(changedValue)) {
+      if (changedValue.length === 0) {
+        // If it's empty assume it's PT and return an empty string
+        return ''
+      }
+      // If the array contains any valid block type, assume the GROQ initially used pt::text on it and do the same conversion
+      if (changedValue.some((node) => typeof node === 'object' && isPortableTextBlock(node))) {
+        return toPlainText(changedValue)
+      }
+    }
+  }
+
+  return changedValue
+}
 
 /**
  * @internal
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f5569de3c07..a1e1cd74f5d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1349,6 +1349,9 @@ importers:
       '@portabletext/react':
         specifier: ^3.0.0
         version: 3.2.0(react@18.3.1)
+      '@portabletext/toolkit':
+        specifier: ^2.0.16
+        version: 2.0.16
       '@rexxars/react-json-inspector':
         specifier: ^9.0.1
         version: 9.0.1(react@18.3.1)