FN-8805: preserve expanded task titles on resize
Keep task-detail title expansion stable across resize observers and task changes. - Measure overflow only in the collapsed title layout. - Preserve explicit expand/collapse selection through stale resize callbacks. - Cover modal and embedded narrow-title behavior with regression tests. - Add a patch changeset for the title stability fix. Files changed: .changeset/fn-8805-stable-task-title.md | 7 ++ packages/dashboard/app/components/TaskDetailModal.tsx | 39 +++++--- packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx | 110 +++++++++++++++++++++ 3 files changed, 141 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-8805 Fusion-Task-Lineage: 66177622-78e8-4378-88f6-c0979a96afd2 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8805-stable-task-title.md
Normal file
7
.changeset/fn-8805-stable-task-title.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep long task-detail titles stable when expanded or collapsed.
|
||||
category: fix
|
||||
dev: Measures title eligibility only from the collapsed layout so resize callbacks preserve the operator's choice.
|
||||
@@ -1199,17 +1199,30 @@ export function TaskDetailContent({
|
||||
}
|
||||
}, [activeTab, task.column, isDoneColumn, detailFlagsAreForThisTask]);
|
||||
|
||||
// Reset description and planner-chat focus state when task changes
|
||||
// Reset planner-chat focus when the operator opens a different task.
|
||||
useEffect(() => {
|
||||
setDescriptionExpanded(false);
|
||||
setPlannerChatExpanded(false);
|
||||
}, [task.column, task.id]);
|
||||
}, [task.id]);
|
||||
|
||||
const [highlightStallCode, setHighlightStallCode] = useState<string | null>(null);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [titleOverflows, setTitleOverflows] = useState(false);
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null);
|
||||
const displayTitleText = task.title || task.description || task.id;
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailTitle 2026-08-05-16:42:
|
||||
Title overflow eligibility belongs to the stable two-line collapsed layout, while expanded or
|
||||
collapsed display is an explicit operator choice. Do not mutate the live expanded heading to
|
||||
measure it: ResizeObserver callbacks caused by that class change can otherwise replace the
|
||||
title-owned control and flicker the detail view. Reset both values before paint only when this
|
||||
task identity or its displayed title/fallback changes; ordinary rerenders and resize callbacks
|
||||
must never reverse the choice.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
setDescriptionExpanded(false);
|
||||
setTitleOverflows(false);
|
||||
}, [displayTitleText, task.id]);
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
@@ -1274,20 +1287,15 @@ export function TaskDetailContent({
|
||||
return;
|
||||
}
|
||||
|
||||
// Expanded headings have natural height, so only the rendered collapsed layout is a valid
|
||||
// overflow measurement. The user choice remains mounted while this observer is disconnected.
|
||||
if (descriptionExpanded) return;
|
||||
|
||||
let cancelled = false;
|
||||
const measureTitleOverflow = () => {
|
||||
let addedCollapsedClass = false;
|
||||
if (descriptionExpanded && !titleElement.classList.contains("detail-title--collapsed")) {
|
||||
titleElement.classList.add("detail-title--collapsed");
|
||||
addedCollapsedClass = true;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
const overflows = titleElement.scrollHeight > titleElement.clientHeight + 1;
|
||||
|
||||
if (addedCollapsedClass) {
|
||||
titleElement.classList.remove("detail-title--collapsed");
|
||||
}
|
||||
|
||||
setTitleOverflows(overflows);
|
||||
setTitleOverflows((previous) => previous === overflows ? previous : overflows);
|
||||
};
|
||||
|
||||
measureTitleOverflow();
|
||||
@@ -1299,6 +1307,7 @@ export function TaskDetailContent({
|
||||
window.addEventListener("resize", measureTitleOverflow);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", measureTitleOverflow);
|
||||
};
|
||||
|
||||
@@ -2284,8 +2284,10 @@ describe("TaskDetailModal", () => {
|
||||
describe("description truncation", () => {
|
||||
let titleScrollHeight = 0;
|
||||
let titleClientHeight = 0;
|
||||
let titleResizeObservers: Array<{ callback: ResizeObserverCallback; disconnected: boolean }> = [];
|
||||
const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
||||
const originalResizeObserver = Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver");
|
||||
|
||||
const setTitleLayout = ({ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }) => {
|
||||
titleScrollHeight = scrollHeight;
|
||||
@@ -2313,6 +2315,22 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
setTitleLayout({ scrollHeight: 120, clientHeight: 40 });
|
||||
titleResizeObservers = [];
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
configurable: true,
|
||||
value: class TitleResizeObserver {
|
||||
private readonly observation: { callback: ResizeObserverCallback; disconnected: boolean };
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.observation = { callback, disconnected: false };
|
||||
titleResizeObservers.push(this.observation);
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() { this.observation.disconnected = true; }
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
@@ -2338,6 +2356,11 @@ describe("TaskDetailModal", () => {
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "clientHeight");
|
||||
}
|
||||
if (originalResizeObserver) {
|
||||
Object.defineProperty(globalThis, "ResizeObserver", originalResizeObserver);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, "ResizeObserver");
|
||||
}
|
||||
});
|
||||
|
||||
it("toggles a long triage title directly without a standalone affordance", async () => {
|
||||
@@ -2373,6 +2396,93 @@ describe("TaskDetailModal", () => {
|
||||
expectNoStandaloneTitleToggle();
|
||||
});
|
||||
|
||||
it("keeps the modal title control stable through repeated resize callbacks after each activation", async () => {
|
||||
const longTitle = "Resize-safe title ".repeat(25);
|
||||
renderDetail({ title: longTitle });
|
||||
|
||||
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
|
||||
const collapsedObserver = titleResizeObservers.at(-1);
|
||||
expect(collapsedObserver).toBeDefined();
|
||||
|
||||
await userEvent.click(titleControl);
|
||||
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
|
||||
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
|
||||
expect(titleControl).toHaveAttribute("aria-expanded", "true");
|
||||
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
|
||||
expect(collapsedObserver?.disconnected).toBe(true);
|
||||
|
||||
// Delivery can race disconnect; a stale collapsed-layout observer must not reclaim the choice.
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 3; index++) {
|
||||
collapsedObserver?.callback([], {} as ResizeObserver);
|
||||
}
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
|
||||
expect(titleControl).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getAllByRole("button", { name: "Collapse task title" })).toHaveLength(1);
|
||||
|
||||
await userEvent.click(titleControl);
|
||||
const recollapsedObserver = titleResizeObservers.at(-1);
|
||||
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
|
||||
expect(screen.getByRole("button", { name: "Expand task title" })).toBe(titleControl);
|
||||
expect(titleControl).toHaveAttribute("aria-expanded", "false");
|
||||
expect(recollapsedObserver).not.toBe(collapsedObserver);
|
||||
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 3; index++) {
|
||||
recollapsedObserver?.callback([], {} as ResizeObserver);
|
||||
}
|
||||
});
|
||||
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
|
||||
expect(screen.getByRole("button", { name: "Expand task title" })).toBe(titleControl);
|
||||
expect(screen.getAllByRole("button", { name: "Expand task title" })).toHaveLength(1);
|
||||
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
|
||||
expectNoStandaloneTitleToggle();
|
||||
});
|
||||
|
||||
it("keeps the embedded narrow title choice stable and ignores a switched task's stale observer", async () => {
|
||||
const longTitle = "Embedded mobile title ".repeat(25);
|
||||
const props = {
|
||||
embedded: true,
|
||||
active: true,
|
||||
initialTab: "definition" as const,
|
||||
onMoveTask: noopMove,
|
||||
onDeleteTask: noopDelete,
|
||||
onMergeTask: noopMerge,
|
||||
onOpenDetail: noopOpenDetail,
|
||||
addToast: noop,
|
||||
};
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
|
||||
const { rerender } = render(<TaskDetailContent {...props} task={makeTask({ id: "FN-EMBEDDED", title: longTitle })} />);
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
|
||||
const oldObserver = titleResizeObservers.at(-1);
|
||||
await userEvent.click(titleControl);
|
||||
await act(async () => {
|
||||
oldObserver?.callback([], {} as ResizeObserver);
|
||||
oldObserver?.callback([], {} as ResizeObserver);
|
||||
});
|
||||
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
|
||||
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
|
||||
|
||||
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
|
||||
rerender(<TaskDetailContent {...props} task={makeTask({ id: "FN-EMBEDDED-NEXT", title: "Narrow fitting title" })} />);
|
||||
await act(async () => {});
|
||||
expect(document.querySelector("h2.detail-title")?.textContent).toBe("Narrow fitting title");
|
||||
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
|
||||
expect(oldObserver?.disconnected).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
oldObserver?.callback([], {} as ResizeObserver);
|
||||
oldObserver?.callback([], {} as ResizeObserver);
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
|
||||
expectNoStandaloneTitleToggle();
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
|
||||
});
|
||||
|
||||
it("supports keyboard activation through the title control", async () => {
|
||||
renderDetail({ title: "Keyboard title ".repeat(25) });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user