Skip to content

Commit 5f2d437

Browse files
samejrclaude
andauthored
fix(webapp): Fix for task page search bar re-rendering bug (#3971)
## Summary Typing in the search bar on the task page could clear or reset the input mid-keystroke. This fixes the re-render race so the field stays stable while you type. ## Root cause Two things compounded: - `SearchInput`'s sync effect depended on `text`, so it re-ran on every keystroke and could overwrite the input with the URL/controlled value while focused. - Each task row unmounted and remounted its activity chart during the side-panel open/close animation (25 charts at once), forcing heavy re-renders that the search effect raced against. ## Fix - `SearchInput` now tracks the last synced value in a ref instead of comparing against `text`, keeping the effect off the keystroke path. It only writes to state when the incoming URL/controlled value actually changes, and never while the input is focused. - Activity charts are now hidden (`hidden` attribute) instead of unmounted during the panel animation, so the rows don't churn the tree and the resize stays smooth. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e829edd commit 5f2d437

3 files changed

Lines changed: 33 additions & 21 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fix the task page search bar clearing or resetting while typing, caused by a re-render race between the input sync effect and the activity charts.

apps/webapp/app/components/primitives/SearchInput.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,27 @@ export function SearchInput({
4747
const [text, setText] = useState(initialSearch);
4848
const [isFocused, setIsFocused] = useState(false);
4949

50+
// Compare against a ref, not `text`, so the effect stays off the keystroke path.
51+
// Trade-off: controlled mode assumes the parent accepts onValueChange; it won't
52+
// re-sync `text` if the parent rejects a change and holds `value` unchanged.
53+
const lastSyncedRef = useRef(initialSearch);
54+
5055
useEffect(() => {
5156
if (isControlled) {
52-
if (controlledValue !== undefined && controlledValue !== text) {
57+
if (controlledValue !== undefined && controlledValue !== lastSyncedRef.current) {
58+
lastSyncedRef.current = controlledValue;
5359
setText(controlledValue);
5460
}
5561
return;
5662
}
5763
const urlSearch = value(paramName) ?? "";
58-
if (urlSearch !== text && !isFocused) {
64+
if (urlSearch === lastSyncedRef.current) return;
65+
// Only mark synced once we actually apply it, so a URL change during focus still syncs on blur.
66+
if (!isFocused) {
67+
lastSyncedRef.current = urlSearch;
5968
setText(urlSearch);
6069
}
61-
}, [isControlled, controlledValue, value, text, isFocused, paramName]);
70+
}, [isControlled, controlledValue, value, isFocused, paramName]);
6271

6372
const updateText = (next: string) => {
6473
setText(next);

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export default function Page() {
193193
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
194194

195195
const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true);
196-
// Unmount the charts while the side panel animates; 25 SVGs in a reflowing table tanks perf.
196+
// Hide (don't unmount) the charts during the panel animation; 25 reflowing SVGs tank the resize.
197197
const [isPanelAnimating, setIsPanelAnimating] = useState(false);
198198
const animatingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
199199
const usefulLinksPanelRef = useRef<PanelHandle>(null);
@@ -436,24 +436,21 @@ function TaskRow({
436436
</Suspense>
437437
</TableCell>
438438
<TableCell to={rowPath} actionClassName="py-1.5">
439-
{/* Reserve the cell footprint while the chart unmounts during the panel animation. */}
440439
<div style={{ width: ACTIVITY_CELL_WIDTH, height: ACTIVITY_CHART_HEIGHT }}>
441-
{!isPanelAnimating && (
442-
<div className="duration-100 animate-in fade-in">
443-
<Suspense fallback={<TaskActivityBlankState />}>
444-
<TypedAwait resolve={hourlyActivity} errorElement={<FailedToLoadStats />}>
445-
{(data) => {
446-
const taskData = data[item.slug];
447-
return taskData && taskData.length > 0 ? (
448-
<TaskActivityGraph activity={taskData} />
449-
) : (
450-
<TaskActivityBlankState />
451-
);
452-
}}
453-
</TypedAwait>
454-
</Suspense>
455-
</div>
456-
)}
440+
<div hidden={isPanelAnimating}>
441+
<Suspense fallback={<TaskActivityBlankState />}>
442+
<TypedAwait resolve={hourlyActivity} errorElement={<FailedToLoadStats />}>
443+
{(data) => {
444+
const taskData = data[item.slug];
445+
return taskData && taskData.length > 0 ? (
446+
<TaskActivityGraph activity={taskData} />
447+
) : (
448+
<TaskActivityBlankState />
449+
);
450+
}}
451+
</TypedAwait>
452+
</Suspense>
453+
</div>
457454
</div>
458455
</TableCell>
459456
<TableCellMenu

0 commit comments

Comments
 (0)