From 8f3a26e8832813394930fa90f6a3af2e6d6d71d9 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Tue, 21 Apr 2026 14:04:12 +0200 Subject: [PATCH] fix(admin): sync inline-editable DOM text imperatively 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 --- .../src/components/admin/InlineEditable.tsx | 25 +++++++++++-------- nextjs/src/components/admin/PriceEditable.tsx | 18 +++++++------ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/nextjs/src/components/admin/InlineEditable.tsx b/nextjs/src/components/admin/InlineEditable.tsx index 9099c2d..5494497 100644 --- a/nextjs/src/components/admin/InlineEditable.tsx +++ b/nextjs/src/components/admin/InlineEditable.tsx @@ -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({ path }) const ref = useRef(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 | React.KeyboardEvent) => { + const commit = (e: React.FocusEvent) => { 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) => { @@ -42,7 +49,7 @@ export function InlineEditable({ path, as: Tag = 'span', className, multiline, p return ( - {value ?? ''} - + /> ) } diff --git a/nextjs/src/components/admin/PriceEditable.tsx b/nextjs/src/components/admin/PriceEditable.tsx index b060319..5b58f6d 100644 --- a/nextjs/src/components/admin/PriceEditable.tsx +++ b/nextjs/src/components/admin/PriceEditable.tsx @@ -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({ path: 'currency' }) const ref = useRef(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) => { @@ -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) => { @@ -48,16 +52,14 @@ export function PriceEditable() { - {formatEuros(value)} - {' '} + />{' '} {symbol} )