fix(ui): respect editorOptions, prevent field from flashing on save (#13848)

Fixes https://github.com/payloadcms/payload/issues/13774

EditorOptions were not being respected properly. The fix for this was to
set the following on the Editor component:
```ts
  detectIndentation: false,
  insertSpaces: undefined,
  tabSize: undefined,
```

### Other fixes
This PR also fixed the flash when JSON fields were saved. It removed the
need for the `editorKey` which was causing the entire field to re-mount
when the json value changed. We had this work around so data could be
set externally and the height would be automatically calculated when the
editor mounted. But since the JSON value did not have a stable reference
there was no way for react to memoize it, so the key would change every
time the document was saved.

Now we pass down a `recalculatedHeightAt` which allows data to be edited
externally still, but tells the component to recalculate its height
without forcing the component to re-mount.
This commit is contained in:
Jarrod Flesch
2025-09-17 15:28:56 -04:00
committed by GitHub
parent 9a8e3f817f
commit 5241113809
5 changed files with 117 additions and 63 deletions

View File

@@ -6,6 +6,7 @@ import type { Props } from './types.js'
import { useTheme } from '../../providers/Theme/index.js'
import { ShimmerEffect } from '../ShimmerEffect/index.js'
import { defaultGlobalEditorOptions, defaultOptions } from './constants.js'
import './index.scss'
const Editor = 'default' in EditorImport ? EditorImport.default : EditorImport
@@ -13,11 +14,21 @@ const Editor = 'default' in EditorImport ? EditorImport.default : EditorImport
const baseClass = 'code-editor'
const CodeEditor: React.FC<Props> = (props) => {
const { className, maxHeight, minHeight, options, readOnly, ...rest } = props
const {
className,
maxHeight,
minHeight,
options,
readOnly,
recalculatedHeightAt,
value,
...rest
} = props
const MIN_HEIGHT = minHeight ?? 56 // equivalent to 3 lines
const prevCalculatedHeightAt = React.useRef<number | undefined>(recalculatedHeightAt)
// Extract per-model settings to avoid global conflicts
const { insertSpaces, tabSize, trimAutoWhitespace, ...editorOptions } = options || {}
const { insertSpaces, tabSize, trimAutoWhitespace, ...globalEditorOptions } = options || {}
const paddingFromProps = options?.padding
? (options.padding.top || 0) + (options.padding?.bottom || 0)
: 0
@@ -34,28 +45,34 @@ const CodeEditor: React.FC<Props> = (props) => {
.filter(Boolean)
.join(' ')
React.useEffect(() => {
if (recalculatedHeightAt && recalculatedHeightAt > prevCalculatedHeightAt.current) {
setDynamicHeight(Math.max(MIN_HEIGHT, value.split('\n').length * 18 + 2 + paddingFromProps))
prevCalculatedHeightAt.current = recalculatedHeightAt
}
}, [value, MIN_HEIGHT, paddingFromProps, recalculatedHeightAt])
return (
<Editor
className={classes}
loading={<ShimmerEffect height={dynamicHeight} />}
options={{
detectIndentation: false, // use the tabSize on the model, set onMount
hideCursorInOverviewRuler: true,
insertSpaces: false,
minimap: {
enabled: false,
},
overviewRulerBorder: false,
...defaultGlobalEditorOptions,
...globalEditorOptions,
readOnly: Boolean(readOnly),
scrollbar: {
alwaysConsumeMouseWheel: false,
},
scrollBeyondLastLine: false,
trimAutoWhitespace: false,
wordWrap: 'on',
...editorOptions,
/**
* onMount the model will set:
* - insertSpaces
* - tabSize
* - trimAutoWhitespace
*/
detectIndentation: false,
insertSpaces: undefined,
tabSize: undefined,
trimAutoWhitespace: undefined,
}}
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
value={value}
{...rest}
// Since we are not building an IDE and the container
// can already have scrolling, we want the height of the
@@ -73,9 +90,9 @@ const CodeEditor: React.FC<Props> = (props) => {
const model = editor.getModel()
if (model) {
model.updateOptions({
insertSpaces: insertSpaces ?? true,
tabSize: tabSize ?? 4,
trimAutoWhitespace: trimAutoWhitespace ?? true,
insertSpaces: insertSpaces ?? defaultOptions.insertSpaces,
tabSize: tabSize ?? defaultOptions.tabSize,
trimAutoWhitespace: trimAutoWhitespace ?? defaultOptions.trimAutoWhitespace,
})
}

View File

@@ -0,0 +1,27 @@
import type { EditorProps } from '@monaco-editor/react'
export const defaultOptions: Pick<
EditorProps['options'],
'insertSpaces' | 'tabSize' | 'trimAutoWhitespace'
> = {
insertSpaces: false,
tabSize: 4,
trimAutoWhitespace: false,
}
export const defaultGlobalEditorOptions: Omit<
EditorProps['options'],
'detectIndentation' | 'insertSpaces' | 'tabSize' | 'trimAutoWhitespace'
> = {
hideCursorInOverviewRuler: true,
minimap: {
enabled: false,
},
overviewRulerBorder: false,
readOnly: false,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
}

View File

@@ -7,4 +7,5 @@ export type Props = {
*/
minHeight?: number
readOnly?: boolean
recalculatedHeightAt?: number
} & EditorProps

View File

@@ -11,8 +11,8 @@ import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
import { mergeFieldStyles } from '../mergeFieldStyles.js'
import './index.scss'
import { fieldBaseClass } from '../shared/index.js'
import './index.scss'
const prismToMonacoLanguageMap = {
js: 'javascript',
@@ -25,7 +25,7 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
const {
field,
field: {
admin: { className, description, editorOptions = {}, language = 'javascript' } = {},
admin: { className, description, editorOptions, language = 'javascript' } = {},
label,
localized,
required,
@@ -36,8 +36,8 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
validate,
} = props
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
const [editorKey, setEditorKey] = useState<string>('')
const inputChangeFromRef = React.useRef<'formState' | 'internalEditor'>('formState')
const [recalculatedHeightAt, setRecalculatedHeightAt] = useState<number | undefined>(Date.now())
const memoizedValidate = useCallback(
(value, options) => {
@@ -61,35 +61,36 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
validate: memoizedValidate,
})
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
const stringValueRef = React.useRef<string>(
(value || initialValue) !== undefined ? (value ?? initialValue) : undefined,
)
const handleChange = useCallback(
(val) => {
(val: string) => {
if (readOnly || disabled) {
return
}
inputChangeFromRef.current = 'user'
inputChangeFromRef.current = 'internalEditor'
try {
setValue(val ? val : null)
stringValueRef.current = val
} catch (e) {
setValue(val ? val : null)
stringValueRef.current = val
}
},
[readOnly, disabled, setValue],
)
useEffect(() => {
if (inputChangeFromRef.current === 'system') {
setInitialStringValue(
(value || initialValue) !== undefined ? (value ?? initialValue) : undefined,
)
setEditorKey(`${path}-${new Date().toString()}`)
if (inputChangeFromRef.current === 'formState') {
stringValueRef.current =
(value || initialValue) !== undefined ? (value ?? initialValue) : undefined
setRecalculatedHeightAt(Date.now())
}
inputChangeFromRef.current = 'system'
inputChangeFromRef.current = 'formState'
}, [initialValue, path, value])
const styles = useMemo(() => mergeFieldStyles(field), [field])
@@ -121,12 +122,12 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
{BeforeInput}
<CodeEditor
defaultLanguage={prismToMonacoLanguageMap[language] || language}
key={editorKey}
onChange={handleChange}
onMount={onMount}
options={editorOptions}
readOnly={readOnly || disabled}
value={initialStringValue}
recalculatedHeightAt={recalculatedHeightAt}
value={stringValueRef.current}
wrapperProps={{
id: `field-${path?.replace(/\./g, '__')}`,
}}

View File

@@ -1,10 +1,11 @@
'use client'
import type { JSONFieldClientComponent } from 'payload'
import type { JSONFieldClientComponent, JsonObject } from 'payload'
import { type OnMount } from '@monaco-editor/react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { defaultOptions } from '../../elements/CodeEditor/constants.js'
import { CodeEditor } from '../../elements/CodeEditor/index.js'
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
import { useField } from '../../forms/useField/index.js'
@@ -33,11 +34,25 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
validate,
} = props
const { tabSize = 2 } = editorOptions || {}
const { insertSpaces = defaultOptions.insertSpaces, tabSize = defaultOptions.tabSize } =
editorOptions || {}
const formatJSON = useCallback(
(value: JsonObject | undefined): string | undefined => {
if (value === undefined) {
return undefined
}
if (value === null) {
return ''
}
return insertSpaces ? JSON.stringify(value, null, tabSize) : JSON.stringify(value, null, '\t')
},
[tabSize, insertSpaces],
)
const [jsonError, setJsonError] = useState<string>()
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
const [editorKey, setEditorKey] = useState<string>('')
const inputChangeFromRef = React.useRef<'formState' | 'internalEditor'>('formState')
const [recalculatedHeightAt, setRecalculatedHeightAt] = useState<number | undefined>(Date.now())
const memoizedValidate = useCallback(
(value, options) => {
@@ -56,16 +71,12 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
setValue,
showError,
value,
} = useField<string>({
} = useField<JsonObject>({
potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
(value || initialValue) !== undefined
? JSON.stringify(value ?? initialValue, null, tabSize)
: undefined,
)
const stringValueRef = React.useRef<string>(formatJSON(value ?? initialValue))
const handleMount = useCallback<OnMount>(
(editor, monaco) => {
@@ -88,28 +99,26 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
: `${uri}?${crypto.randomUUID ? crypto.randomUUID() : uuidv4()}`
editor.setModel(
monaco.editor.createModel(
JSON.stringify(value, null, tabSize),
'json',
monaco.Uri.parse(newUri),
),
monaco.editor.createModel(formatJSON(value) || '', 'json', monaco.Uri.parse(newUri)),
)
},
[jsonSchema, tabSize, value],
[jsonSchema, formatJSON, value],
)
const handleChange = useCallback(
(val) => {
(val: string) => {
if (readOnly || disabled) {
return
}
inputChangeFromRef.current = 'user'
inputChangeFromRef.current = 'internalEditor'
try {
setValue(val ? JSON.parse(val) : null)
stringValueRef.current = val
setJsonError(undefined)
} catch (e) {
setValue(val ? val : null)
stringValueRef.current = val
setJsonError(e)
}
},
@@ -117,17 +126,16 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
)
useEffect(() => {
if (inputChangeFromRef.current === 'system') {
setInitialStringValue(
(value || initialValue) !== undefined
? JSON.stringify(value ?? initialValue, null, tabSize)
: undefined,
)
setEditorKey(`${path}-${new Date().toString()}`)
if (inputChangeFromRef.current === 'formState') {
const newStringValue = formatJSON(value ?? initialValue)
if (newStringValue !== stringValueRef.current) {
stringValueRef.current = newStringValue
setRecalculatedHeightAt(Date.now())
}
}
inputChangeFromRef.current = 'system'
}, [initialValue, path, tabSize, value])
inputChangeFromRef.current = 'formState'
}, [initialValue, path, formatJSON, value])
const styles = useMemo(() => mergeFieldStyles(field), [field])
@@ -158,13 +166,13 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
{BeforeInput}
<CodeEditor
defaultLanguage="json"
key={editorKey}
maxHeight={maxHeight}
onChange={handleChange}
onMount={handleMount}
options={editorOptions}
readOnly={readOnly || disabled}
value={initialStringValue}
recalculatedHeightAt={recalculatedHeightAt}
value={stringValueRef.current}
wrapperProps={{
id: `field-${path?.replace(/\./g, '__')}`,
}}