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">
{(entries || []).map((entry) => (
<EntryForm
key={entry.id}
key={entry.clientKey || entry.id}
entry={entry}
homeowners={homeowners}
onHomeownerAdded={onHomeownerAdded}
+68 -23
View File
@@ -58,6 +58,16 @@ export default function Timesheet() {
const [overtime, setOvertime] = useState(null);
const saveTimer = useRef(null);
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 weekParam = formatWeekParam(selectedDate);
@@ -85,10 +95,12 @@ export default function Timesheet() {
// Ensure at least one empty entry per day
weekDates.forEach((d) => {
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);
setTimesheet(timesheetRes.data?.timesheet || timesheetRes.data);
setHomeowners(homeownersRes.data.homeowners || homeownersRes.data || []);
@@ -124,18 +136,51 @@ export default function Timesheet() {
};
try {
if (data._isNew || entryId.startsWith('new-')) {
const res = await api.post('/entries', payload);
// Replace temp ID with real ID
setEntries((prev) => {
const dk = data.date;
return {
...prev,
[dk]: prev[dk].map((e) => (e.id === entryId ? { ...res.data.entry || res.data, date: dk } : e)),
};
const isTemp = entryId.startsWith('new-');
let realId = isTemp ? idMap.current[entryId] : entryId;
// 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) => {
const dk = data.date;
return {
...prev,
[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 {
await api.put(`/entries/${entryId}`, payload);
await api.put(`/entries/${realId}`, payload);
}
setSaveStatus('saved');
} catch (err) {
@@ -164,27 +209,27 @@ export default function Timesheet() {
}
function handleAddEntry(date) {
const newEntry = {
id: `new-${date}-${Date.now()}`,
date,
homeownerId: '',
hoursWorked: '',
workDescription: '',
_isNew: true,
};
const newEntry = makeNewEntry(date);
setEntries((prev) => ({ ...prev, [date]: [...(prev[date] || []), newEntry] }));
}
async function handleDeleteEntry(entryId) {
try {
if (!entryId.startsWith('new-')) {
await api.delete(`/entries/${entryId}`);
// Resolve a temp id to its server id if the create already happened
// (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) => {
const updated = {};
Object.entries(prev).forEach(([dk, dayEntries]) => {
const filtered = dayEntries.filter((e) => e.id !== entryId);
updated[dk] = filtered.length > 0 ? filtered : [{ id: `new-${dk}-0`, date: dk, homeownerId: '', hoursWorked: '', workDescription: '', _isNew: true }];
const filtered = dayEntries.filter((e) => e.id !== entryId && e.id !== realId);
updated[dk] = filtered.length > 0 ? filtered : [makeNewEntry(dk)];
});
return updated;
});