Skip to content

Commit ec4da46

Browse files
authoredMar 8, 2024
fix(comments): lost comment message while document is reconnecting (#5928)
1 parent c230bb5 commit ec4da46

File tree

10 files changed

+151
-28
lines changed

10 files changed

+151
-28
lines changed
 

‎packages/sanity/src/structure/comments/plugin/document-layout/CommentsDocumentLayout.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {type DocumentLayoutProps} from 'sanity'
44
import {useDocumentPane} from '../../..'
55
import {COMMENTS_INSPECTOR_NAME} from '../../../panes/document/constants'
66
import {
7+
CommentsAuthoringPathProvider,
78
CommentsEnabledProvider,
89
CommentsProvider,
910
CommentsSelectedPathProvider,
@@ -43,7 +44,9 @@ function CommentsDocumentLayoutInner(props: DocumentLayoutProps) {
4344
isCommentsOpen={inspector?.name === COMMENTS_INSPECTOR_NAME}
4445
onCommentsOpen={handleOpenCommentsInspector}
4546
>
46-
<CommentsSelectedPathProvider>{props.renderDefault(props)}</CommentsSelectedPathProvider>
47+
<CommentsSelectedPathProvider>
48+
<CommentsAuthoringPathProvider>{props.renderDefault(props)}</CommentsAuthoringPathProvider>
49+
</CommentsSelectedPathProvider>
4750
</CommentsProvider>
4851
)
4952
}

‎packages/sanity/src/structure/comments/plugin/field/CommentsField.tsx

+61-23
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import styled, {css} from 'styled-components'
1111
import {
1212
applyCommentsFieldAttr,
1313
type CommentCreatePayload,
14+
type CommentMessage,
1415
type CommentsUIMode,
1516
isTextSelectionComment,
1617
useComments,
18+
useCommentsAuthoringPath,
1719
useCommentsEnabled,
1820
useCommentsScroll,
1921
useCommentsSelectedPath,
@@ -22,6 +24,14 @@ import {
2224
import {COMMENTS_HIGHLIGHT_HUE_KEY} from '../../src/constants'
2325
import {CommentsFieldButton} from './CommentsFieldButton'
2426

27+
// When the form is temporarily set to `readOnly` while reconnecting, the form
28+
// will be re-rendered and any comment that is being authored will be lost.
29+
// To avoid this, we cache the comment message in a map and restore it when the
30+
// field is re-rendered.
31+
const messageCache = new Map<string, CommentMessage>()
32+
33+
const EMPTY_ARRAY: [] = []
34+
2535
const HIGHLIGHT_BLOCK_VARIANTS: Variants = {
2636
initial: {
2737
opacity: 0,
@@ -74,8 +84,6 @@ function CommentFieldInner(
7484
},
7585
) {
7686
const {mode} = props
77-
const [open, setOpen] = useState<boolean>(false)
78-
const [value, setValue] = useState<PortableTextBlock[] | null>(null)
7987

8088
const currentUser = useCurrentUser()
8189
const {element: boundaryElement} = useBoundaryElement()
@@ -94,18 +102,28 @@ function CommentFieldInner(
94102
} = useComments()
95103
const {upsellData, handleOpenDialog} = useCommentsUpsell()
96104
const {selectedPath, setSelectedPath} = useCommentsSelectedPath()
105+
const {authoringPath, setAuthoringPath} = useCommentsAuthoringPath()
97106
const {scrollToGroup} = useCommentsScroll({
98107
boundaryElement,
99108
})
100109

101110
const fieldTitle = useMemo(() => getSchemaTypeTitle(props.schemaType), [props.schemaType])
111+
const stringPath = useMemo(() => PathUtils.toString(props.path), [props.path])
112+
113+
// Use the cached value if it exists as the initial value
114+
const cachedValue = messageCache.get(stringPath) || null
115+
116+
const [value, setValue] = useState<PortableTextBlock[] | null>(cachedValue)
117+
118+
// If the path of the field matches the authoring path, the comment input should be open.
119+
const isOpen = useMemo(() => authoringPath === stringPath, [authoringPath, stringPath])
102120

103121
// Determine if the current field is selected
104122
const isSelected = useMemo(() => {
105123
if (!isCommentsOpen) return false
106124
if (selectedPath?.origin === 'form' || selectedPath?.origin === 'url') return false
107-
return selectedPath?.fieldPath === PathUtils.toString(props.path)
108-
}, [isCommentsOpen, props.path, selectedPath?.fieldPath, selectedPath?.origin])
125+
return selectedPath?.fieldPath === stringPath
126+
}, [isCommentsOpen, selectedPath?.fieldPath, selectedPath?.origin, stringPath])
109127

110128
const isInlineCommentThread = useMemo(() => {
111129
return comments.data.open
@@ -115,17 +133,21 @@ function CommentFieldInner(
115133

116134
// Total number of comments for the current field
117135
const count = useMemo(() => {
118-
const stringPath = PathUtils.toString(props.path)
119-
120136
const commentsCount = comments.data.open
121137
.map((c) => (c.fieldPath === stringPath ? c.commentsCount : 0))
122138
.reduce((acc, val) => acc + val, 0)
123139

124140
return commentsCount || 0
125-
}, [comments.data.open, props.path])
141+
}, [comments.data.open, stringPath])
126142

127143
const hasComments = Boolean(count > 0)
128144

145+
const resetMessageValue = useCallback(() => {
146+
// Reset the value and remove the message from the cache
147+
setValue(null)
148+
messageCache.delete(stringPath)
149+
}, [stringPath])
150+
129151
const handleClick = useCallback(() => {
130152
// When clicking a comment button when the field has comments, we want to:
131153
if (hasComments) {
@@ -134,8 +156,9 @@ function CommentFieldInner(
134156
setStatus('open')
135157
}
136158

137-
// 2. Close the comment input if it's open
138-
setOpen(false)
159+
// 2. Ensure that the authoring path is reset when clicking
160+
// the comment button when the field has comments.
161+
setAuthoringPath(null)
139162

140163
// 3. Open the comments inspector
141164
onCommentsOpen?.()
@@ -171,19 +194,24 @@ function CommentFieldInner(
171194
return
172195
}
173196

174-
// Else, toggle the comment input open/closed
175-
setOpen((v) => !v)
197+
// If the field is open (i.e. the authoring path is set to the current field)
198+
// we close the field by resetting the authoring path. If the field is not open,
199+
// we set the authoring path to the current field so that the comment form is opened.
200+
setAuthoringPath(isOpen ? null : stringPath)
176201
}, [
177202
comments.data.open,
178203
handleOpenDialog,
179204
hasComments,
205+
isOpen,
180206
mode,
181207
onCommentsOpen,
182208
props.path,
183209
scrollToGroup,
210+
setAuthoringPath,
184211
setSelectedPath,
185212
setStatus,
186213
status,
214+
stringPath,
187215
upsellData,
188216
])
189217

@@ -200,7 +228,7 @@ function CommentFieldInner(
200228
status: 'open',
201229
threadId: newThreadId,
202230
// New comments have no reactions
203-
reactions: [],
231+
reactions: EMPTY_ARRAY,
204232
}
205233

206234
// Execute the create mutation
@@ -216,8 +244,7 @@ function CommentFieldInner(
216244
setStatus('open')
217245
}
218246

219-
// Reset the value
220-
setValue(null)
247+
resetMessageValue()
221248

222249
// Scroll to the thread
223250
setSelectedPath({
@@ -232,14 +259,23 @@ function CommentFieldInner(
232259
onCommentsOpen,
233260
operation,
234261
props.path,
262+
resetMessageValue,
235263
scrollToGroup,
236264
setSelectedPath,
237265
setStatus,
238266
status,
239267
value,
240268
])
241269

242-
const handleDiscard = useCallback(() => setValue(null), [])
270+
const handleClose = useCallback(() => setAuthoringPath(null), [setAuthoringPath])
271+
272+
const handleOnChange = useCallback(
273+
(nextValue: CommentMessage) => {
274+
setValue(nextValue)
275+
messageCache.set(stringPath, nextValue)
276+
},
277+
[stringPath],
278+
)
243279

244280
const internalComments: FieldProps['__internal_comments'] = useMemo(
245281
() => ({
@@ -250,29 +286,31 @@ function CommentFieldInner(
250286
fieldTitle={fieldTitle}
251287
isCreatingDataset={isCreatingDataset}
252288
mentionOptions={mentionOptions}
253-
onChange={setValue}
289+
onChange={handleOnChange}
254290
onClick={handleClick}
291+
onClose={handleClose}
255292
onCommentAdd={handleCommentAdd}
256-
onDiscard={handleDiscard}
257-
open={open}
258-
setOpen={setOpen}
293+
onDiscard={resetMessageValue}
294+
open={isOpen}
259295
value={value}
260296
/>
261297
),
262298
hasComments,
263-
isAddingComment: open,
299+
isAddingComment: isOpen,
264300
}),
265301
[
266302
currentUser,
267303
count,
268304
fieldTitle,
305+
isCreatingDataset,
269306
mentionOptions,
307+
handleOnChange,
270308
handleClick,
309+
handleClose,
271310
handleCommentAdd,
272-
handleDiscard,
273-
open,
311+
resetMessageValue,
312+
isOpen,
274313
value,
275-
isCreatingDataset,
276314
hasComments,
277315
],
278316
)

‎packages/sanity/src/structure/comments/plugin/field/CommentsFieldButton.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ interface CommentsFieldButtonProps {
3939
mentionOptions: UserListWithPermissionsHookValue
4040
onChange: (value: PortableTextBlock[]) => void
4141
onClick?: () => void
42+
onClose: () => void
4243
onCommentAdd: () => void
4344
onDiscard: () => void
4445
onInputKeyDown?: (event: React.KeyboardEvent<Element>) => void
4546
open: boolean
46-
setOpen: (open: boolean) => void
4747
value: CommentMessage
4848
}
4949

@@ -56,11 +56,11 @@ export function CommentsFieldButton(props: CommentsFieldButtonProps) {
5656
mentionOptions,
5757
onChange,
5858
onClick,
59+
onClose,
5960
onCommentAdd,
6061
onDiscard,
6162
onInputKeyDown,
6263
open,
63-
setOpen,
6464
value,
6565
} = props
6666
const {t} = useTranslation(commentsLocaleNamespace)
@@ -73,9 +73,9 @@ export function CommentsFieldButton(props: CommentsFieldButtonProps) {
7373

7474
const closePopover = useCallback(() => {
7575
if (!open) return
76-
setOpen(false)
76+
onClose()
7777
addCommentButtonElement?.focus()
78-
}, [addCommentButtonElement, open, setOpen])
78+
}, [addCommentButtonElement, open, onClose])
7979

8080
const handleSubmit = useCallback(() => {
8181
onCommentAdd()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {createContext} from 'react'
2+
3+
import {type CommentsAuthoringPathContextValue} from './types'
4+
5+
/**
6+
* @beta
7+
* @hidden
8+
*/
9+
export const CommentsAuthoringPathContext = createContext<CommentsAuthoringPathContextValue | null>(
10+
null,
11+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {type ReactNode, useCallback, useMemo, useState} from 'react'
2+
3+
import {CommentsAuthoringPathContext} from './CommentsAuthoringPathContext'
4+
import {type CommentsAuthoringPathContextValue} from './types'
5+
6+
interface CommentsAuthoringPathProviderProps {
7+
children: ReactNode
8+
}
9+
10+
/**
11+
* @beta
12+
* @hidden
13+
* This provider keeps track of the path that the user is currently authoring a comment for.
14+
* This is needed to make sure that we consistently keep the editor open when the user is
15+
* authoring a comment. The state is kept in a context to make sure that it is preserved
16+
* across re-renders. If this state was kept in a component, it would be reset every time
17+
* the component re-renders, for example, when the form is temporarily set to `readOnly`
18+
* while reconnecting.
19+
*/
20+
export function CommentsAuthoringPathProvider(props: CommentsAuthoringPathProviderProps) {
21+
const {children} = props
22+
const [authoringPath, setAuthoringPath] = useState<string | null>(null)
23+
24+
const handleSetAuthoringPath = useCallback((nextAuthoringPath: string | null) => {
25+
setAuthoringPath(nextAuthoringPath)
26+
}, [])
27+
28+
const value = useMemo(
29+
(): CommentsAuthoringPathContextValue => ({
30+
authoringPath,
31+
setAuthoringPath: handleSetAuthoringPath,
32+
}),
33+
[authoringPath, handleSetAuthoringPath],
34+
)
35+
36+
return (
37+
<CommentsAuthoringPathContext.Provider value={value}>
38+
{children}
39+
</CommentsAuthoringPathContext.Provider>
40+
)
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './CommentsAuthoringPathContext'
2+
export * from './CommentsAuthoringPathProvider'
3+
export * from './types'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @beta
3+
* @hidden
4+
*/
5+
export interface CommentsAuthoringPathContextValue {
6+
setAuthoringPath: (nextAuthoringPath: string | null) => void
7+
authoringPath: string | null
8+
}

‎packages/sanity/src/structure/comments/src/context/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './authoring-path'
12
export * from './comments'
23
export * from './enabled'
34
export * from './intent'

‎packages/sanity/src/structure/comments/src/hooks/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './use-comment-operations'
22
export * from './useComments'
3+
export * from './useCommentsAuthoringPath'
34
export * from './useCommentsEnabled'
45
export * from './useCommentsIntent'
56
export * from './useCommentsOnboarding'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {useContext} from 'react'
2+
3+
import {CommentsAuthoringPathContext, type CommentsAuthoringPathContextValue} from '../context'
4+
5+
/**
6+
* @beta
7+
* @hidden
8+
*/
9+
export function useCommentsAuthoringPath(): CommentsAuthoringPathContextValue {
10+
const value = useContext(CommentsAuthoringPathContext)
11+
12+
if (!value) {
13+
throw new Error('useCommentsAuthoringPath: missing context value')
14+
}
15+
16+
return value
17+
}

0 commit comments

Comments
 (0)
Please sign in to comment.