Compare commits

...

5 Commits

Author SHA1 Message Date
Kyle Carberry
986d6a856d perf(site): bypass React re-renders during panel drag-resize
The main bottleneck was setWidth() firing on every pointermove,
which re-rendered RightPanel → SidebarTabView → GitPanel →
DiffViewer → all FileDiff components on every pixel of movement.

Now during drag, the --panel-width CSS custom property is set
directly on the DOM via panelRef, skipping React reconciliation
entirely. React state is only committed on pointerup for
localStorage persistence.

With this change the only React re-renders during a normal drag
are: one on the first pointermove (dragSnap null → normal) and
one on pointerup. Everything in between is pure DOM mutation.
2026-03-18 17:00:44 +00:00
Kyle Carberry
351ab6c7c7 chore: apply biome formatting fixes 2026-03-18 16:52:19 +00:00
Kyle Carberry
d3d0ea3622 feat(site): add StressLargeDiff story for manual perf testing
Adds a story with 100 chat messages and a 10,000-line diff (50 files
x 200 lines each) with the sidebar panel open. Useful for manually
verifying that drag-resize and scroll feel smooth.
2026-03-18 16:36:51 +00:00
Kyle Carberry
c0b71a1161 fix(site): remove spacer div from DiffViewer 2026-03-18 16:34:40 +00:00
Kyle Carberry
828c9e23f5 fix(site): reduce diff panel lag during drag-resize with CSS containment
Add three CSS-level optimizations to improve responsiveness when
dragging the right panel resize handle with large diffs open:

- content-visibility: auto on each file diff wrapper in DiffViewer so
  the browser skips layout/paint for off-screen diffs entirely during
  resize and scroll.
- contain: layout style paint on the RightPanel children container and
  SidebarTabView tab panel to isolate the diff subtree from external
  layout recalculation.
- pointer-events: none on panel children during active drag to
  eliminate hit-testing against the expensive Shadow DOM elements from
  @pierre/diffs.
2026-03-18 16:32:42 +00:00
4 changed files with 151 additions and 13 deletions

View File

@@ -428,3 +428,85 @@ export const NotFoundSidebarCollapsed: Story = {
/>
),
};
// ---------------------------------------------------------------------------
// Stress-test story: large chat + huge diff
// ---------------------------------------------------------------------------
/**
* Generate a unified diff string with the given number of changed
* files, each containing `linesPerFile` added lines. Repeats a
* simple pattern so the story code stays small.
*/
function generateHugeDiff(fileCount: number, linesPerFile: number): string {
const chunks: string[] = [];
for (let f = 0; f < fileCount; f++) {
const path = `src/components/Module${f}/index.tsx`;
chunks.push(
`diff --git a/${path} b/${path}`,
"index 0000000..1111111 100644",
`--- a/${path}`,
`+++ b/${path}`,
`@@ -1,0 +1,${linesPerFile} @@`,
);
for (let l = 0; l < linesPerFile; l++) {
chunks.push(
`+// Line ${l + 1}: ${path} — filler to stress-test rendering performance`,
);
}
}
return chunks.join("\n");
}
const STRESS_FILE_COUNT = 50;
const STRESS_LINES_PER_FILE = 200; // 50 × 200 = 10 000 changed lines
const STRESS_TOTAL_ADDITIONS = STRESS_FILE_COUNT * STRESS_LINES_PER_FILE;
/** Build a long chat history with alternating user/assistant messages. */
function generateLongChatHistory(messageCount: number): TypesGen.ChatMessage[] {
const msgs: TypesGen.ChatMessage[] = [];
for (let i = 0; i < messageCount; i++) {
const role: TypesGen.ChatMessageRole = i % 2 === 0 ? "user" : "assistant";
const text =
role === "user"
? `Request #${Math.floor(i / 2) + 1}: Please refactor the next batch of modules.`
: `Done! I've updated modules ${Math.floor(i / 2) * 5}-${Math.floor(i / 2) * 5 + 4} with the new patterns. Here's a summary of the changes:\n\n- Extracted shared hooks\n- Replaced class components with functional components\n- Added proper TypeScript types\n- Cleaned up unused imports\n- Added unit tests for critical paths`;
msgs.push(buildMessage(i + 1, role, text));
}
return msgs;
}
/**
* Stress-test story: 100 chat messages + 10 000-line diff with
* the sidebar panel open. Use this to manually verify that
* drag-resizing and scrolling feel smooth.
*/
export const StressLargeDiff: Story = {
args: {
store: buildStoreWithMessages(generateLongChatHistory(100)),
showSidebarPanel: true,
prNumber: 456,
hasMoreMessages: false,
isFetchingMoreMessages: false,
onFetchMoreMessages: fn(),
diffStatusData: {
chat_id: AGENT_ID,
url: "https://github.com/coder/coder/pull/456",
pull_request_title: "refactor: massive module extraction",
pull_request_state: "open",
pull_request_draft: false,
changes_requested: false,
additions: STRESS_TOTAL_ADDITIONS,
deletions: 0,
changed_files: STRESS_FILE_COUNT,
base_branch: "main",
head_branch: "refactor/module-extraction",
} satisfies ChatDiffStatus,
},
beforeEach: () => {
spyOn(API, "getChatDiffContents").mockResolvedValue({
chat_id: AGENT_ID,
diff: generateHugeDiff(STRESS_FILE_COUNT, STRESS_LINES_PER_FILE),
});
},
};

View File

@@ -727,6 +727,13 @@ export const DiffViewer: FC<DiffViewerProps> = ({
<div
key={fileDiff.name}
ref={(el) => setFileRef(fileDiff.name, el)}
style={{
// Allow the browser to skip layout/paint for
// off-screen file diffs, dramatically reducing
// the work during panel resize or scroll.
contentVisibility: "auto",
containIntrinsicSize: `auto ${estimateDiffHeight(fileDiff)}px`,
}}
>
<LazyFileDiff
fileDiff={fileDiff}
@@ -740,8 +747,6 @@ export const DiffViewer: FC<DiffViewerProps> = ({
/>
</div>
))}
{/* Spacer so the last file can scroll fully to the top. */}
<div className="h-[calc(100vh-100px)]" />
</div>
</ScrollArea>
</div>

View File

@@ -55,6 +55,12 @@ interface RightPanelProps {
* Encapsulates all drag/resize logic for the right panel:
* refs, pointer handlers, snap state, and visual state
* derivation.
*
* During a drag the panel width is applied directly to the DOM
* via the panelRef, bypassing React state. This avoids
* re-rendering the entire diff subtree on every pointermove.
* React state is only committed on pointerup so localStorage
* persistence and non-drag renders stay correct.
*/
function useResizableDrag({
isExpanded,
@@ -75,10 +81,23 @@ function useResizableDrag({
isSidebarCollapsed?: boolean;
onToggleSidebarCollapsed?: () => void;
}) {
const isDragging = useRef(false);
const isDraggingRef = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);
const sidebarCollapsedByDrag = useRef(false);
// Live width tracked in a ref so we can read/write it during
// drag without triggering React re-renders. Synced from React
// state when not dragging (e.g. after window-resize clamp).
const liveWidthRef = useRef(width);
if (!isDraggingRef.current) {
liveWidthRef.current = width;
}
// Ref to the panel DOM element for direct style mutations
// during drag, avoiding React re-renders.
const panelRef = useRef<HTMLDivElement>(null);
// Track snap state during a drag. This is state (not a ref) so
// the panel visually updates as the user drags across thresholds.
const [dragSnap, setDragSnap] = useState<
@@ -88,7 +107,7 @@ function useResizableDrag({
const handlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
isDragging.current = true;
isDraggingRef.current = true;
setDragSnap(null);
sidebarCollapsedByDrag.current = false;
startX.current = e.clientX;
@@ -96,15 +115,15 @@ function useResizableDrag({
? ((e.target as HTMLElement).closest(
"[data-testid='agents-right-panel']",
)?.parentElement?.clientWidth ?? getMaxWidth())
: width;
: liveWidthRef.current;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[width, isExpanded],
[isExpanded],
);
const handlePointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDragging.current) {
if (!isDraggingRef.current) {
return;
}
const delta = startX.current - e.clientX;
@@ -135,7 +154,12 @@ function useResizableDrag({
nextSnap = "closed";
} else {
nextSnap = "normal";
setWidth(Math.min(maxWidth, Math.max(MIN_WIDTH, raw)));
const clamped = Math.min(maxWidth, Math.max(MIN_WIDTH, raw));
liveWidthRef.current = clamped;
// Mutate the DOM directly instead of calling
// setWidth() — this skips React reconciliation
// for the entire panel subtree on every frame.
panelRef.current?.style.setProperty("--panel-width", `${clamped}px`);
}
setDragSnap(nextSnap);
@@ -147,7 +171,6 @@ function useResizableDrag({
onVisualExpandedChange?.(nextVisualExpanded);
},
[
setWidth,
isExpanded,
onVisualExpandedChange,
isSidebarCollapsed,
@@ -157,11 +180,11 @@ function useResizableDrag({
const handlePointerUp = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDragging.current) {
if (!isDraggingRef.current) {
return;
}
const snap = dragSnap;
isDragging.current = false;
isDraggingRef.current = false;
setDragSnap(null);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
@@ -169,11 +192,15 @@ function useResizableDrag({
// own committed expanded state.
onVisualExpandedChange?.(null);
// Commit the final width to React state so it persists
// to localStorage and is used for the next render.
setWidth(liveWidthRef.current);
if (snap) {
onSnapCommit(snap);
}
},
[dragSnap, onSnapCommit, onVisualExpandedChange],
[dragSnap, onSnapCommit, onVisualExpandedChange, setWidth],
);
// Derive visual state: during a drag the snap overrides the
@@ -185,9 +212,14 @@ function useResizableDrag({
dragSnap !== "closed" &&
(dragSnap === "expanded" || dragSnap === "normal" || isOpen);
// Dragging is active whenever a snap override is in effect.
const isDragging = dragSnap !== null;
return {
visualExpanded,
visualOpen,
isDragging,
panelRef,
handlePointerDown,
handlePointerMove,
handlePointerUp,
@@ -236,6 +268,8 @@ export const RightPanel = ({
const {
visualExpanded,
visualOpen,
isDragging,
panelRef,
handlePointerDown,
handlePointerMove,
handlePointerUp,
@@ -258,6 +292,7 @@ export const RightPanel = ({
return (
<div
ref={panelRef}
data-testid="agents-right-panel"
style={
visualOpen && !visualExpanded
@@ -285,7 +320,18 @@ export const RightPanel = ({
visualExpanded && "-left-1",
)}
/>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
<div
className="flex min-h-0 flex-1 flex-col"
style={{
// During drag-resize, disable pointer events on children
// to skip expensive hit-testing on Shadow DOM diff
// elements, and isolate this subtree from layout recalc.
contain: "layout style paint",
pointerEvents: isDragging ? "none" : undefined,
}}
>
{children}
</div>
</div>
);
};

View File

@@ -325,6 +325,11 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
effectiveTabId ? `${tabIdPrefix}-tab-${effectiveTabId}` : undefined
}
className="min-h-0 flex-1"
style={{
// Isolate this subtree so the browser doesn't need to
// re-check external layout when the panel resizes.
contain: "layout style paint",
}}
>
{effectiveTabId === "desktop" && desktopChatId ? (
<DesktopPanel