diff --git a/services/platform/app/features/tasks/components/task-log-window.browser.test.tsx b/services/platform/app/features/tasks/components/task-log-window.browser.test.tsx index f8aba0d423..8d2ee4d0f1 100644 --- a/services/platform/app/features/tasks/components/task-log-window.browser.test.tsx +++ b/services/platform/app/features/tasks/components/task-log-window.browser.test.tsx @@ -326,6 +326,88 @@ describe('task modal log window', () => { expect(list.querySelectorAll('[data-index]').length).toBeLessThan(40); }); + it.each(['blurred', 'focused'] as const)( + 'retains the same %s offscreen editor when the discussion crosses the window threshold', + async (focus) => { + const comments = state.comments.slice(0, 120); + state.comments = comments.slice(0, 90); + const view = render(); + const scroller = screen.getByTestId('scrollport'); + const list = scroller.querySelector('ul'); + if (list === null) throw new Error('Missing comment list'); + expect(list.querySelectorAll('[data-index]')).toHaveLength(90); + await scrollTo(scroller, listStart(list, scroller)); + const comment = await screen.findByText('Comment 0 opening line.'); + const row = comment.closest('li'); + if (row === null) throw new Error('Missing comment row'); + await userEvent.click( + within(row).getByRole('button', { name: 'actions.edit' }), + ); + const field = within(row).getByRole('textbox'); + const draft = 'Draft retained across windowing'; + await userEvent.fill(field, draft); + expect(field).toHaveFocus(); + if (focus === 'blurred') { + await userEvent.click( + screen.getByRole('button', { name: 'detail.showEarlierComments' }), + ); + expect(field).not.toHaveFocus(); + expect(row.contains(document.activeElement)).toBe(false); + } + await scrollTo( + scroller, + listStart(list, scroller) + list.offsetHeight - scroller.clientHeight, + ); + await waitFor(() => { + expect(screen.getByText('Comment 89 opening line.')).toBeVisible(); + expect(row.getBoundingClientRect().bottom).toBeLessThan( + scroller.getBoundingClientRect().top, + ); + }); + // Allow the shared focus bridge's deferred blur release to finish before + // crossing. Otherwise its focus pin could mask missing edit registration. + await act(async () => { + for (let frame = 0; frame < 3; frame++) { + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + } + }); + if (focus === 'focused') expect(field).toHaveFocus(); + else expect(row.contains(document.activeElement)).toBe(false); + state.comments = comments; + view.rerender(); + await waitFor(() => { + expect(list.querySelectorAll('[data-index]').length).toBeGreaterThan(0); + expect(list.querySelectorAll('[data-index]').length).toBeLessThan(40); + // A nearby unpinned row must leave the range as well: bounded rows + // alone would not prove that the edit is outside the visible window. + expect(screen.queryByText('Comment 10 opening line.')).toBeNull(); + expect(row.getBoundingClientRect().bottom).toBeLessThan( + scroller.getBoundingClientRect().top, + ); + }); + await act(async () => { + for (let frame = 0; frame < 3; frame++) { + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + } + }); + expect(list.querySelectorAll('[data-index]').length).toBeLessThan(40); + expect(field).toBeInTheDocument(); + expect(within(row).getByRole('textbox')).toBe(field); + expect(field).toHaveValue(draft); + if (focus === 'focused') { + expect(field).toHaveFocus(); + expect(document.activeElement).not.toBe(document.body); + } else { + expect(field).not.toHaveFocus(); + expect(row.contains(document.activeElement)).toBe(false); + } + }, + ); + it('keeps an edit draft mounted after scrolling into older pages and moving focus', async () => { render(); const scroller = screen.getByTestId('scrollport'); diff --git a/services/platform/app/features/tasks/hooks/use-task-log-window.tsx b/services/platform/app/features/tasks/hooks/use-task-log-window.tsx index cd1f3a5995..ec4ef9c9d0 100644 --- a/services/platform/app/features/tasks/hooks/use-task-log-window.tsx +++ b/services/platform/app/features/tasks/hooks/use-task-log-window.tsx @@ -20,7 +20,6 @@ const TaskLogViewportContext = type RowKey = string | number; type SetRowActive = (rowKey: RowKey, source: string, active: boolean) => void; -const NOOP_SET_ROW_ACTIVE: SetRowActive = () => undefined; const TaskLogRowActivityContext = createContext< ((source: string, active: boolean) => void) | null >(null); @@ -170,12 +169,12 @@ export function useTaskLogWindow({ return () => observer.disconnect(); }, [getScrollElement, window.scrollElement, listElement]); - // Small discussions render every row and do not need an activity pin. A - // no-op keeps editor focus from causing a second list reconciliation in that - // common case; large windows still pin dirty or portaled rows as they leave - // the viewport. + // Register activity in every mode. A row can become active while the list is + // below the window threshold and remain active when new rows turn + // virtualization on; keeping the registration avoids dropping its draft on + // that crossing render. return Object.assign(window, { listRef: attachList, - setRowActive: window.virtualized ? setRowActive : NOOP_SET_ROW_ACTIVE, + setRowActive, }); }