diff --git a/packages/sanity/src/structure/comments/plugin/document-layout/CommentsDocumentLayout.tsx b/packages/sanity/src/structure/comments/plugin/document-layout/CommentsDocumentLayout.tsx
index 17b15fcc4c09..ae10da9de3f9 100644
--- a/packages/sanity/src/structure/comments/plugin/document-layout/CommentsDocumentLayout.tsx
+++ b/packages/sanity/src/structure/comments/plugin/document-layout/CommentsDocumentLayout.tsx
@@ -4,6 +4,7 @@ import {type DocumentLayoutProps} from 'sanity'
import {useDocumentPane} from '../../..'
import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants'
import {
+ CommentsAuthoringPathProvider,
CommentsEnabledProvider,
CommentsProvider,
CommentsSelectedPathProvider,
@@ -43,7 +44,9 @@ function CommentsDocumentLayoutInner(props: DocumentLayoutProps) {
isCommentsOpen={inspector?.name === COMMENTS_INSPECTOR_NAME}
onCommentsOpen={handleOpenCommentsInspector}
>
- {props.renderDefault(props)}
+
+ {props.renderDefault(props)}
+
)
}
diff --git a/packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx b/packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx
index 5742f7aef98c..2b22642541a0 100644
--- a/packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx
+++ b/packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx
@@ -11,9 +11,11 @@ import styled, {css} from 'styled-components'
import {
applyCommentsFieldAttr,
type CommentCreatePayload,
+ type CommentMessage,
type CommentsUIMode,
isTextSelectionComment,
useComments,
+ useCommentsAuthoringPath,
useCommentsEnabled,
useCommentsScroll,
useCommentsSelectedPath,
@@ -22,6 +24,14 @@ import {
import {COMMENTS_HIGHLIGHT_HUE_KEY} from '../../src/constants'
import {CommentsFieldButton} from './CommentsFieldButton'
+// When the form is temporarily set to `readOnly` while reconnecting, the form
+// will be re-rendered and any comment that is being authored will be lost.
+// To avoid this, we cache the comment message in a map and restore it when the
+// field is re-rendered.
+const messageCache = new Map()
+
+const EMPTY_ARRAY: [] = []
+
const HIGHLIGHT_BLOCK_VARIANTS: Variants = {
initial: {
opacity: 0,
@@ -74,8 +84,6 @@ function CommentFieldInner(
},
) {
const {mode} = props
- const [open, setOpen] = useState(false)
- const [value, setValue] = useState(null)
const currentUser = useCurrentUser()
const {element: boundaryElement} = useBoundaryElement()
@@ -94,18 +102,28 @@ function CommentFieldInner(
} = useComments()
const {upsellData, handleOpenDialog} = useCommentsUpsell()
const {selectedPath, setSelectedPath} = useCommentsSelectedPath()
+ const {authoringPath, setAuthoringPath} = useCommentsAuthoringPath()
const {scrollToGroup} = useCommentsScroll({
boundaryElement,
})
const fieldTitle = useMemo(() => getSchemaTypeTitle(props.schemaType), [props.schemaType])
+ const stringPath = useMemo(() => PathUtils.toString(props.path), [props.path])
+
+ // Use the cached value if it exists as the initial value
+ const cachedValue = messageCache.get(stringPath) || null
+
+ const [value, setValue] = useState(cachedValue)
+
+ // If the path of the field matches the authoring path, the comment input should be open.
+ const isOpen = useMemo(() => authoringPath === stringPath, [authoringPath, stringPath])
// Determine if the current field is selected
const isSelected = useMemo(() => {
if (!isCommentsOpen) return false
if (selectedPath?.origin === 'form' || selectedPath?.origin === 'url') return false
- return selectedPath?.fieldPath === PathUtils.toString(props.path)
- }, [isCommentsOpen, props.path, selectedPath?.fieldPath, selectedPath?.origin])
+ return selectedPath?.fieldPath === stringPath
+ }, [isCommentsOpen, selectedPath?.fieldPath, selectedPath?.origin, stringPath])
const isInlineCommentThread = useMemo(() => {
return comments.data.open
@@ -115,17 +133,21 @@ function CommentFieldInner(
// Total number of comments for the current field
const count = useMemo(() => {
- const stringPath = PathUtils.toString(props.path)
-
const commentsCount = comments.data.open
.map((c) => (c.fieldPath === stringPath ? c.commentsCount : 0))
.reduce((acc, val) => acc + val, 0)
return commentsCount || 0
- }, [comments.data.open, props.path])
+ }, [comments.data.open, stringPath])
const hasComments = Boolean(count > 0)
+ const resetMessageValue = useCallback(() => {
+ // Reset the value and remove the message from the cache
+ setValue(null)
+ messageCache.delete(stringPath)
+ }, [stringPath])
+
const handleClick = useCallback(() => {
// When clicking a comment button when the field has comments, we want to:
if (hasComments) {
@@ -134,8 +156,9 @@ function CommentFieldInner(
setStatus('open')
}
- // 2. Close the comment input if it's open
- setOpen(false)
+ // 2. Ensure that the authoring path is reset when clicking
+ // the comment button when the field has comments.
+ setAuthoringPath(null)
// 3. Open the comments inspector
onCommentsOpen?.()
@@ -171,19 +194,24 @@ function CommentFieldInner(
return
}
- // Else, toggle the comment input open/closed
- setOpen((v) => !v)
+ // If the field is open (i.e. the authoring path is set to the current field)
+ // we close the field by resetting the authoring path. If the field is not open,
+ // we set the authoring path to the current field so that the comment form is opened.
+ setAuthoringPath(isOpen ? null : stringPath)
}, [
comments.data.open,
handleOpenDialog,
hasComments,
+ isOpen,
mode,
onCommentsOpen,
props.path,
scrollToGroup,
+ setAuthoringPath,
setSelectedPath,
setStatus,
status,
+ stringPath,
upsellData,
])
@@ -200,7 +228,7 @@ function CommentFieldInner(
status: 'open',
threadId: newThreadId,
// New comments have no reactions
- reactions: [],
+ reactions: EMPTY_ARRAY,
}
// Execute the create mutation
@@ -216,8 +244,7 @@ function CommentFieldInner(
setStatus('open')
}
- // Reset the value
- setValue(null)
+ resetMessageValue()
// Scroll to the thread
setSelectedPath({
@@ -232,6 +259,7 @@ function CommentFieldInner(
onCommentsOpen,
operation,
props.path,
+ resetMessageValue,
scrollToGroup,
setSelectedPath,
setStatus,
@@ -239,7 +267,15 @@ function CommentFieldInner(
value,
])
- const handleDiscard = useCallback(() => setValue(null), [])
+ const handleClose = useCallback(() => setAuthoringPath(null), [setAuthoringPath])
+
+ const handleOnChange = useCallback(
+ (nextValue: CommentMessage) => {
+ setValue(nextValue)
+ messageCache.set(stringPath, nextValue)
+ },
+ [stringPath],
+ )
const internalComments: FieldProps['__internal_comments'] = useMemo(
() => ({
@@ -250,29 +286,31 @@ function CommentFieldInner(
fieldTitle={fieldTitle}
isCreatingDataset={isCreatingDataset}
mentionOptions={mentionOptions}
- onChange={setValue}
+ onChange={handleOnChange}
onClick={handleClick}
+ onClose={handleClose}
onCommentAdd={handleCommentAdd}
- onDiscard={handleDiscard}
- open={open}
- setOpen={setOpen}
+ onDiscard={resetMessageValue}
+ open={isOpen}
value={value}
/>
),
hasComments,
- isAddingComment: open,
+ isAddingComment: isOpen,
}),
[
currentUser,
count,
fieldTitle,
+ isCreatingDataset,
mentionOptions,
+ handleOnChange,
handleClick,
+ handleClose,
handleCommentAdd,
- handleDiscard,
- open,
+ resetMessageValue,
+ isOpen,
value,
- isCreatingDataset,
hasComments,
],
)
diff --git a/packages/sanity/src/structure/comments/plugin/field/CommentsFieldButton.tsx b/packages/sanity/src/structure/comments/plugin/field/CommentsFieldButton.tsx
index dd2bac4bdd85..e718e61c7434 100644
--- a/packages/sanity/src/structure/comments/plugin/field/CommentsFieldButton.tsx
+++ b/packages/sanity/src/structure/comments/plugin/field/CommentsFieldButton.tsx
@@ -39,11 +39,11 @@ interface CommentsFieldButtonProps {
mentionOptions: UserListWithPermissionsHookValue
onChange: (value: PortableTextBlock[]) => void
onClick?: () => void
+ onClose: () => void
onCommentAdd: () => void
onDiscard: () => void
onInputKeyDown?: (event: React.KeyboardEvent) => void
open: boolean
- setOpen: (open: boolean) => void
value: CommentMessage
}
@@ -56,11 +56,11 @@ export function CommentsFieldButton(props: CommentsFieldButtonProps) {
mentionOptions,
onChange,
onClick,
+ onClose,
onCommentAdd,
onDiscard,
onInputKeyDown,
open,
- setOpen,
value,
} = props
const {t} = useTranslation(commentsLocaleNamespace)
@@ -73,9 +73,9 @@ export function CommentsFieldButton(props: CommentsFieldButtonProps) {
const closePopover = useCallback(() => {
if (!open) return
- setOpen(false)
+ onClose()
addCommentButtonElement?.focus()
- }, [addCommentButtonElement, open, setOpen])
+ }, [addCommentButtonElement, open, onClose])
const handleSubmit = useCallback(() => {
onCommentAdd()
diff --git a/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathContext.ts b/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathContext.ts
new file mode 100644
index 000000000000..be967daf1dd0
--- /dev/null
+++ b/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathContext.ts
@@ -0,0 +1,11 @@
+import {createContext} from 'react'
+
+import {type CommentsAuthoringPathContextValue} from './types'
+
+/**
+ * @beta
+ * @hidden
+ */
+export const CommentsAuthoringPathContext = createContext(
+ null,
+)
diff --git a/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathProvider.tsx b/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathProvider.tsx
new file mode 100644
index 000000000000..0136a337c364
--- /dev/null
+++ b/packages/sanity/src/structure/comments/src/context/authoring-path/CommentsAuthoringPathProvider.tsx
@@ -0,0 +1,41 @@
+import {type ReactNode, useCallback, useMemo, useState} from 'react'
+
+import {CommentsAuthoringPathContext} from './CommentsAuthoringPathContext'
+import {type CommentsAuthoringPathContextValue} from './types'
+
+interface CommentsAuthoringPathProviderProps {
+ children: ReactNode
+}
+
+/**
+ * @beta
+ * @hidden
+ * This provider keeps track of the path that the user is currently authoring a comment for.
+ * This is needed to make sure that we consistently keep the editor open when the user is
+ * authoring a comment. The state is kept in a context to make sure that it is preserved
+ * across re-renders. If this state was kept in a component, it would be reset every time
+ * the component re-renders, for example, when the form is temporarily set to `readOnly`
+ * while reconnecting.
+ */
+export function CommentsAuthoringPathProvider(props: CommentsAuthoringPathProviderProps) {
+ const {children} = props
+ const [authoringPath, setAuthoringPath] = useState(null)
+
+ const handleSetAuthoringPath = useCallback((nextAuthoringPath: string | null) => {
+ setAuthoringPath(nextAuthoringPath)
+ }, [])
+
+ const value = useMemo(
+ (): CommentsAuthoringPathContextValue => ({
+ authoringPath,
+ setAuthoringPath: handleSetAuthoringPath,
+ }),
+ [authoringPath, handleSetAuthoringPath],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/packages/sanity/src/structure/comments/src/context/authoring-path/index.ts b/packages/sanity/src/structure/comments/src/context/authoring-path/index.ts
new file mode 100644
index 000000000000..e9ae4e57df38
--- /dev/null
+++ b/packages/sanity/src/structure/comments/src/context/authoring-path/index.ts
@@ -0,0 +1,3 @@
+export * from './CommentsAuthoringPathContext'
+export * from './CommentsAuthoringPathProvider'
+export * from './types'
diff --git a/packages/sanity/src/structure/comments/src/context/authoring-path/types.ts b/packages/sanity/src/structure/comments/src/context/authoring-path/types.ts
new file mode 100644
index 000000000000..fe25cc8e391d
--- /dev/null
+++ b/packages/sanity/src/structure/comments/src/context/authoring-path/types.ts
@@ -0,0 +1,8 @@
+/**
+ * @beta
+ * @hidden
+ */
+export interface CommentsAuthoringPathContextValue {
+ setAuthoringPath: (nextAuthoringPath: string | null) => void
+ authoringPath: string | null
+}
diff --git a/packages/sanity/src/structure/comments/src/context/index.ts b/packages/sanity/src/structure/comments/src/context/index.ts
index 999141977686..6bf11d1b0a87 100644
--- a/packages/sanity/src/structure/comments/src/context/index.ts
+++ b/packages/sanity/src/structure/comments/src/context/index.ts
@@ -1,3 +1,4 @@
+export * from './authoring-path'
export * from './comments'
export * from './enabled'
export * from './intent'
diff --git a/packages/sanity/src/structure/comments/src/hooks/index.ts b/packages/sanity/src/structure/comments/src/hooks/index.ts
index df3bc2a4237b..cfbd1a70da9f 100644
--- a/packages/sanity/src/structure/comments/src/hooks/index.ts
+++ b/packages/sanity/src/structure/comments/src/hooks/index.ts
@@ -1,5 +1,6 @@
export * from './use-comment-operations'
export * from './useComments'
+export * from './useCommentsAuthoringPath'
export * from './useCommentsEnabled'
export * from './useCommentsIntent'
export * from './useCommentsOnboarding'
diff --git a/packages/sanity/src/structure/comments/src/hooks/useCommentsAuthoringPath.ts b/packages/sanity/src/structure/comments/src/hooks/useCommentsAuthoringPath.ts
new file mode 100644
index 000000000000..3f6256ca974f
--- /dev/null
+++ b/packages/sanity/src/structure/comments/src/hooks/useCommentsAuthoringPath.ts
@@ -0,0 +1,17 @@
+import {useContext} from 'react'
+
+import {CommentsAuthoringPathContext, type CommentsAuthoringPathContextValue} from '../context'
+
+/**
+ * @beta
+ * @hidden
+ */
+export function useCommentsAuthoringPath(): CommentsAuthoringPathContextValue {
+ const value = useContext(CommentsAuthoringPathContext)
+
+ if (!value) {
+ throw new Error('useCommentsAuthoringPath: missing context value')
+ }
+
+ return value
+}