Compare commits

1 Commits

Author SHA1 Message Date
Roberto
81fe6d29e2 perf(DateTimeField): keep typing local, memoize Calendar + SegmentSpan
Typing in a segment no longer calls onChange — local state only.
onChange now fires only on commit (Enter, calendar pick), so the
parent TaskFormDialog stops re-rendering on every keystroke (and
the heavy Calendar grid + every pill / popover / query stops
re-rendering with it).

Inside DateTimeField:
- Calendar element memoized via useMemo keyed on the committed
  date's ms — only re-renders when a full valid date is reached
  or changes.
- SegmentSpan wrapped in React.memo.
- onSegKeyDown stabilized via useCallback + functional setSeg +
  refs for order / withTime / onChange / onCommit, so its
  identity never changes.
- Per-segment ref setters cached in a useRef map so they don't
  swap identity on each render.

TaskFormDialog:
- onChange / onCommit passed to DateTimeField wrapped in useCallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:46:47 +02:00
2 changed files with 131 additions and 90 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
@@ -368,6 +368,13 @@ export function TaskFormDialog({
setAssigneeInput('');
}
const handleDueChange = useCallback((d: Date | undefined) => {
setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }));
}, []);
const handleDueCommit = useCallback(() => {
setDueOpen(false);
}, []);
const { data: projectsList = [] } = trpc.projects.listAll.useQuery();
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
const prefs = useFormatPrefs();
@@ -542,8 +549,8 @@ export function TaskFormDialog({
<DateTimeField
withTime
value={values.dueDate ? new Date(values.dueDate) : undefined}
onChange={(d) => setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }))}
onCommit={() => setDueOpen(false)}
onChange={handleDueChange}
onCommit={handleDueCommit}
aria-label={t('tasks.colDue')}
/>
</PopoverContent>

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useFormatPrefs, type FormatPrefs } from '@/lib/date';
import { Calendar } from '@/components/ui/calendar';
@@ -89,6 +89,14 @@ export function DateTimeField({
const refs = useRef<Record<SegKey, HTMLSpanElement | null>>({
day: null, month: null, year: null, hour: null, minute: null,
});
// Stable per-segment ref setters (avoid new-function-per-render).
const refSetters = useRef<Record<SegKey, (el: HTMLSpanElement | null) => void>>({
day: (el) => { refs.current.day = el; },
month: (el) => { refs.current.month = el; },
year: (el) => { refs.current.year = el; },
hour: (el) => { refs.current.hour = el; },
minute: (el) => { refs.current.minute = el; },
});
function focusSeg(key: SegKey) {
const el = refs.current[key];
@@ -101,117 +109,149 @@ export function DateTimeField({
sel?.addRange(range);
}
function focusNext(curr: SegKey) {
const idx = order.indexOf(curr);
const next = order[idx + 1];
if (next) focusSeg(next);
}
function focusPrev(curr: SegKey) {
const idx = order.indexOf(curr);
const prev = order[idx - 1];
if (prev) focusSeg(prev);
}
// Note: typing updates LOCAL state only. We deliberately don't call
// onChange on every keystroke — otherwise the parent re-renders on each
// keypress, which re-renders the (heavy) Calendar grid and the rest of
// TaskFormDialog. onChange only fires on commit (Enter) or calendar pick.
function applyState(next: SegState) {
setSeg(next);
const dt = toDate(next, withTime);
onChange(dt);
}
// Stable across renders: uses functional setSeg, refs, and order via ref.
const orderRef = useRef(order);
orderRef.current = order;
const withTimeRef = useRef(withTime);
withTimeRef.current = withTime;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onCommitRef = useRef(onCommit);
onCommitRef.current = onCommit;
function commit(state: SegState) {
const today = new Date();
const filled: SegState = {
day: state.day || String(today.getDate()).padStart(2, '0'),
month: state.month || String(today.getMonth() + 1).padStart(2, '0'),
year: state.year || String(today.getFullYear()),
hour: withTime ? (state.hour || '00') : '00',
minute: withTime ? (state.minute || '00') : '00',
};
const monthN = clamp(parseInt(filled.month, 10), SEGS.month.min, SEGS.month.max);
const yearN = clamp(parseInt(filled.year, 10), SEGS.year.min, SEGS.year.max);
const hourN = clamp(parseInt(filled.hour, 10), SEGS.hour.min, SEGS.hour.max);
const minuteN = clamp(parseInt(filled.minute, 10), SEGS.minute.min, SEGS.minute.max);
const lastDayOfMonth = new Date(yearN, monthN, 0).getDate();
const dayN = clamp(parseInt(filled.day, 10), SEGS.day.min, lastDayOfMonth);
const dt = new Date(yearN, monthN - 1, dayN, hourN, minuteN, 0, 0);
const finalState = fromDate(dt);
setSeg(finalState);
onChange(dt);
onCommit?.(dt);
}
function onSegKeyDown(e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) {
const onSegKeyDown = useCallback((e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => {
const def = SEGS[key];
const cur = seg[key];
if (e.key === 'ArrowRight') {
e.preventDefault();
focusNext(key);
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
focusPrev(key);
const idx = orderRef.current.indexOf(key);
const prv = orderRef.current[idx - 1];
if (prv) focusSeg(prv);
return;
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const delta = e.key === 'ArrowUp' ? 1 : -1;
const base = cur === '' ? (key === 'hour' || key === 'minute' ? 0 : def.min) : parseInt(cur, 10);
let n = base + delta;
if (n < def.min) n = def.max;
if (n > def.max) n = def.min;
const next = { ...seg, [key]: String(n).padStart(def.len, '0') };
applyState(next);
setSeg((prev) => {
const cur = prev[key];
const delta = e.key === 'ArrowUp' ? 1 : -1;
const base = cur === '' ? (key === 'hour' || key === 'minute' ? 0 : def.min) : parseInt(cur, 10);
let n = base + delta;
if (n < def.min) n = def.max;
if (n > def.max) n = def.min;
return { ...prev, [key]: String(n).padStart(def.len, '0') };
});
return;
}
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
if (cur === '') {
focusPrev(key);
} else {
const next = { ...seg, [key]: '' };
applyState(next);
}
setSeg((prev) => {
if (prev[key] === '') {
const idx = orderRef.current.indexOf(key);
const prv = orderRef.current[idx - 1];
if (prv) focusSeg(prv);
return prev;
}
return { ...prev, [key]: '' };
});
return;
}
if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
const incoming = cur.length >= def.len ? e.key : cur + e.key;
const numeric = parseInt(incoming, 10);
const final = numeric > def.max ? e.key : incoming;
const padded = final.padStart(Math.min(final.length, def.len), '0');
const next = { ...seg, [key]: padded };
applyState(next);
if (padded.length >= def.len || parseInt(padded, 10) * 10 > def.max) {
focusNext(key);
let advance = false;
setSeg((prev) => {
const cur = prev[key];
const incoming = cur.length >= def.len ? e.key : cur + e.key;
const numeric = parseInt(incoming, 10);
const final = numeric > def.max ? e.key : incoming;
const padded = final.padStart(Math.min(final.length, def.len), '0');
if (padded.length >= def.len || parseInt(padded, 10) * 10 > def.max) {
advance = true;
}
return { ...prev, [key]: padded };
});
if (advance) {
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
commit(seg);
// Read current seg via functional updater; commit then propagate.
setSeg((prev) => {
const today = new Date();
const wt = withTimeRef.current;
const filled: SegState = {
day: prev.day || String(today.getDate()).padStart(2, '0'),
month: prev.month || String(today.getMonth() + 1).padStart(2, '0'),
year: prev.year || String(today.getFullYear()),
hour: wt ? (prev.hour || '00') : '00',
minute: wt ? (prev.minute || '00') : '00',
};
const monthN = clamp(parseInt(filled.month, 10), SEGS.month.min, SEGS.month.max);
const yearN = clamp(parseInt(filled.year, 10), SEGS.year.min, SEGS.year.max);
const hourN = clamp(parseInt(filled.hour, 10), SEGS.hour.min, SEGS.hour.max);
const minuteN = clamp(parseInt(filled.minute, 10), SEGS.minute.min, SEGS.minute.max);
const lastDayOfMonth = new Date(yearN, monthN, 0).getDate();
const dayN = clamp(parseInt(filled.day, 10), SEGS.day.min, lastDayOfMonth);
const dt = new Date(yearN, monthN - 1, dayN, hourN, minuteN, 0, 0);
onChangeRef.current(dt);
onCommitRef.current?.(dt);
return fromDate(dt);
});
return;
}
if (e.key === '/' || e.key === '-' || e.key === ':' || e.key === ' ') {
e.preventDefault();
focusNext(key);
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
return;
}
}
}, []);
function onCalendarSelect(d: Date | undefined) {
const onCalendarSelect = useCallback((d: Date | undefined) => {
if (!d) return;
const next: SegState = {
...seg,
day: String(d.getDate()).padStart(2, '0'),
month: String(d.getMonth() + 1).padStart(2, '0'),
year: String(d.getFullYear()),
};
applyState(next);
}
setSeg((prev) => {
const next: SegState = {
...prev,
day: String(d.getDate()).padStart(2, '0'),
month: String(d.getMonth() + 1).padStart(2, '0'),
year: String(d.getFullYear()),
};
const dt = toDate(next, withTime);
if (dt) onChange(dt);
return next;
});
}, [withTime, onChange]);
const selectedDate = toDate(seg, withTime);
const selectedMs = selectedDate ? selectedDate.getTime() : null;
const calendarEl = useMemo(
() => (
<Calendar
mode="single"
selected={selectedDate}
onSelect={onCalendarSelect}
/>
),
// selectedMs primary key; selectedDate/onCalendarSelect captured for closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedMs, onCalendarSelect],
);
return (
<div className={cn('flex flex-col gap-3', className)} aria-label={rest['aria-label']}>
@@ -225,7 +265,7 @@ export function DateTimeField({
segKey={sk}
value={seg[sk]}
onKeyDown={onSegKeyDown}
registerRef={(el) => { refs.current[sk] = el; }}
registerRef={refSetters.current[sk]}
sep={sep}
/>
))}
@@ -236,31 +276,25 @@ export function DateTimeField({
segKey="hour"
value={seg.hour}
onKeyDown={onSegKeyDown}
registerRef={(el) => { refs.current.hour = el; }}
registerRef={refSetters.current.hour}
sep=":"
/>
<SegmentSpan
segKey="minute"
value={seg.minute}
onKeyDown={onSegKeyDown}
registerRef={(el) => { refs.current.minute = el; }}
registerRef={refSetters.current.minute}
sep={null}
/>
</>
)}
</div>
<div className="rounded-md border">
<Calendar
mode="single"
selected={selectedDate}
onSelect={onCalendarSelect}
/>
</div>
<div className="rounded-md border">{calendarEl}</div>
</div>
);
}
function SegmentSpan({
const SegmentSpan = memo(function SegmentSpan({
segKey,
value,
onKeyDown,
@@ -305,4 +339,4 @@ function SegmentSpan({
{sep && <span className="text-muted-foreground/70 select-none px-0.5">{sep}</span>}
</>
);
}
});