fix: keep entry form mounted when a new entry is first saved

On mobile, typing the first characters of a work description on a new
entry caused the form to remount: the auto-save POST replaced the temp
entry with the server copy, changing its React key, which destroyed the
textarea, dropped focus and closed the keyboard.

- Key EntryForm on a stable clientKey that survives the id swap
- Merge only the server id into the local entry so keystrokes typed
  while the create request is in flight are kept
- Track temp-id -> real-id and in-flight creates so queued changes
  become PUTs instead of duplicate POSTs; deletes resolve to the real row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 20:01:59 -04:00
co-authored by Claude Fable 5.1
parent 9952d873c7
commit 5cfcce0b64
2 changed files with 69 additions and 24 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ export default function DayCard({ date, entries, homeowners, onEntryChange, onAd
<div className="px-4 pb-4 space-y-3"> <div className="px-4 pb-4 space-y-3">
{(entries || []).map((entry) => ( {(entries || []).map((entry) => (
<EntryForm <EntryForm
key={entry.id} key={entry.clientKey || entry.id}
entry={entry} entry={entry}
homeowners={homeowners} homeowners={homeowners}
onHomeownerAdded={onHomeownerAdded} onHomeownerAdded={onHomeownerAdded}
+63 -18
View File
@@ -58,6 +58,16 @@ export default function Timesheet() {
const [overtime, setOvertime] = useState(null); const [overtime, setOvertime] = useState(null);
const saveTimer = useRef(null); const saveTimer = useRef(null);
const pendingChanges = useRef({}); const pendingChanges = useRef({});
// Client-created entries keep a temp id until the server assigns a real one.
// idMap: tempId -> realId (after create resolves)
// creating: tempId -> Promise<realId> (while create is in flight)
const idMap = useRef({});
const creating = useRef({});
function makeNewEntry(date) {
const id = `new-${date}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return { id, clientKey: id, date, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true };
}
const weekDates = getWeekDates(selectedDate); const weekDates = getWeekDates(selectedDate);
const weekParam = formatWeekParam(selectedDate); const weekParam = formatWeekParam(selectedDate);
@@ -85,10 +95,12 @@ export default function Timesheet() {
// Ensure at least one empty entry per day // Ensure at least one empty entry per day
weekDates.forEach((d) => { weekDates.forEach((d) => {
if (byDate[d].length === 0) { if (byDate[d].length === 0) {
byDate[d] = [{ id: `new-${d}-0`, date: d, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }]; byDate[d] = [makeNewEntry(d)];
} }
}); });
idMap.current = {};
creating.current = {};
setEntries(byDate); setEntries(byDate);
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data); setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []); setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
@@ -124,18 +136,51 @@ export default function Timesheet() {
}; };
try { try {
if (data._isNew || entryId.startsWith('new-')) { const isTemp = entryId.startsWith('new-');
const res = await api.post('/entries', payload); let realId = isTemp ? idMap.current[entryId] : entryId;
// Replace temp ID with real ID
// A create for this temp id is already in flight — wait for it, then update.
if (isTemp && !realId && creating.current[entryId]) {
realId = await creating.current[entryId];
}
if (isTemp && !realId) {
const createPromise = api.post('/entries', payload).then((res) => {
const created = res.data.entry || res.data;
idMap.current[entryId] = created.id;
// Merge the server id into the existing local entry. Keep the
// clientKey (so React does not remount the form) and keep whatever
// the user has typed since the request was sent.
setEntries((prev) => { setEntries((prev) => {
const dk = data.date; const dk = data.date;
return { return {
...prev, ...prev,
[dk]: prev[dk].map((e) => (e.id === entryId ? { ...res.data.entry || res.data, date: dk } : e)), [dk]: (prev[dk] || []).map((e) =>
e.id === entryId
? {
...e,
id: created.id,
clientKey: e.clientKey,
date: dk,
homeownerId: e.homeownerId,
hoursWorked: e.hoursWorked,
workDescription: e.workDescription,
_isNew: false,
}
: e
),
}; };
}); });
return created.id;
});
creating.current[entryId] = createPromise;
try {
await createPromise;
} finally {
delete creating.current[entryId];
}
} else { } else {
await api.put(`/entries/${entryId}`, payload); await api.put(`/entries/${realId}`, payload);
} }
setSaveStatus('saved'); setSaveStatus('saved');
} catch (err) { } catch (err) {
@@ -164,27 +209,27 @@ export default function Timesheet() {
} }
function handleAddEntry(date) { function handleAddEntry(date) {
const newEntry = { const newEntry = makeNewEntry(date);
id: `new-${date}-${Date.now()}`,
date,
homeownerId: '',
hoursWorked: '',
workDescription: '',
_isNew: true,
};
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] })); setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
} }
async function handleDeleteEntry(entryId) { async function handleDeleteEntry(entryId) {
try { try {
if (!entryId.startsWith('new-')) { // Resolve a temp id to its server id if the create already happened
await api.delete(`/entries/${entryId}`); // (or is still in flight) so we do not orphan a row on the server.
let realId = entryId;
if (entryId.startsWith('new-')) {
realId = idMap.current[entryId] || (creating.current[entryId] ? await creating.current[entryId] : null);
} }
if (realId) {
await api.delete(`/entries/${realId}`);
}
delete pendingChanges.current[entryId];
setEntries((prev) => { setEntries((prev) => {
const updated = {}; const updated = {};
Object.entries(prev).forEach(([dk, dayEntries]) => { Object.entries(prev).forEach(([dk, dayEntries]) => {
const filtered = dayEntries.filter((e) => e.id !== entryId); const filtered = dayEntries.filter((e) => e.id !== entryId && e.id !== realId);
updated[dk] = filtered.length > 0 ? filtered : [{ id: `new-${dk}-0`, date: dk, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }]; updated[dk] = filtered.length > 0 ? filtered : [makeNewEntry(dk)];
}); });
return updated; return updated;
}); });