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

View File

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