fix(admin): sync inline-editable DOM text imperatively
All checks were successful
Build & Deploy to K3s / build-and-deploy (push) Successful in 2m45s

React 19 does not reconcile children of contentEditable elements
after mount, so fields whose useField value arrives async (on most
paths under the tabs wrapper) stayed visually empty even though the
form state had the right value.

Drive textContent from a useLayoutEffect keyed on value, skip updates
while the element has focus (user typing), and let commit() write back
on blur. Same fix applied to PriceEditable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-21 14:04:12 +02:00
parent f5671008a7
commit 8f3a26e883
2 changed files with 25 additions and 18 deletions

View File

@ -1,7 +1,7 @@
'use client'
import { useField } from '@payloadcms/ui'
import { useEffect, useRef } from 'react'
import { useLayoutEffect, useRef } from 'react'
type Props = {
path: string
@ -15,15 +15,22 @@ export function InlineEditable({ path, as: Tag = 'span', className, multiline, p
const { value, setValue } = useField<string>({ path })
const ref = useRef<HTMLElement | null>(null)
useEffect(() => {
if (ref.current && ref.current.textContent !== (value ?? '')) {
ref.current.textContent = value ?? ''
}
// Sync textContent from value. React doesn't reconcile children of
// contentEditable elements, so we drive the DOM imperatively — and
// never clobber the DOM while the user is actively typing in it.
useLayoutEffect(() => {
const el = ref.current
if (!el) return
if (typeof document !== 'undefined' && document.activeElement === el) return
const next = value ?? ''
if (el.textContent !== next) el.textContent = next
el.classList.toggle('rebours-editable--empty', !value)
}, [value])
const commit = (e: React.FocusEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => {
const commit = (e: React.FocusEvent<HTMLElement>) => {
const next = (e.currentTarget.textContent ?? '').replace(/\u00A0/g, ' ')
if (next !== (value ?? '')) setValue(next)
e.currentTarget.classList.toggle('rebours-editable--empty', !next)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
@ -42,7 +49,7 @@ export function InlineEditable({ path, as: Tag = 'span', className, multiline, p
return (
<Tag
ref={ref as never}
className={`rebours-editable${className ? ' ' + className : ''}${!value ? ' rebours-editable--empty' : ''}`}
className={`rebours-editable${className ? ' ' + className : ''}`}
contentEditable
suppressContentEditableWarning
spellCheck={false}
@ -51,8 +58,6 @@ export function InlineEditable({ path, as: Tag = 'span', className, multiline, p
onPaste={onPaste}
data-placeholder={placeholder ?? ''}
data-as={Tag}
>
{value ?? ''}
</Tag>
/>
)
}

View File

@ -1,7 +1,7 @@
'use client'
import { useField } from '@payloadcms/ui'
import { useEffect, useRef } from 'react'
import { useLayoutEffect, useRef } from 'react'
function formatEuros(cents: number | null | undefined): string {
if (cents == null) return ''
@ -22,10 +22,13 @@ export function PriceEditable() {
const { value: currency } = useField<string>({ path: 'currency' })
const ref = useRef<HTMLSpanElement | null>(null)
useEffect(() => {
if (!ref.current) return
useLayoutEffect(() => {
const el = ref.current
if (!el) return
if (typeof document !== 'undefined' && document.activeElement === el) return
const rendered = formatEuros(value)
if (ref.current.textContent !== rendered) ref.current.textContent = rendered
if (el.textContent !== rendered) el.textContent = rendered
el.classList.toggle('rebours-editable--empty', value == null)
}, [value])
const commit = (e: React.FocusEvent<HTMLSpanElement>) => {
@ -33,6 +36,7 @@ export function PriceEditable() {
const cents = parseEuros(txt)
if (cents !== value) setValue(cents)
e.currentTarget.textContent = formatEuros(cents)
e.currentTarget.classList.toggle('rebours-editable--empty', cents == null)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
@ -48,16 +52,14 @@ export function PriceEditable() {
<span className="checkout-price">
<span
ref={ref}
className={`rebours-editable${!value ? ' rebours-editable--empty' : ''}`}
className="rebours-editable"
contentEditable
suppressContentEditableWarning
spellCheck={false}
onBlur={commit}
onKeyDown={onKeyDown}
data-placeholder="—"
>
{formatEuros(value)}
</span>{' '}
/>{' '}
{symbol}
</span>
)