fix(ws): prevent message list from scrolling on request pane tab switch (#8448)

This commit is contained in:
Pooja
2026-07-07 20:42:34 +05:30
committed by GitHub
parent 707489ef77
commit 958ca49156
7 changed files with 1044 additions and 17 deletions

View File

@@ -39,7 +39,8 @@ export const SingleWSMessage = ({
isNew,
onNewRendered,
isSelected,
onSelect
onSelect,
paneHeight
}) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
@@ -105,11 +106,16 @@ export const SingleWSMessage = ({
const fontSize = get(preferences, 'font.codeFontSize', 14);
const lineHeight = fontSize * 1.5;
const HEADER_ALLOWANCE = 44;
const maxEditorHeight = paneHeight
? Math.max(160, paneHeight - HEADER_ALLOWANCE)
: Math.round((typeof window !== 'undefined' ? window.innerHeight : 900) * 0.6);
const editorHeight = useMemo(() => {
const lineCount = (content || '').split('\n').length;
const lines = lineCount + 1;
return `${lines * lineHeight + 10}px`;
}, [content, lineHeight]);
const contentHeight = lines * lineHeight + 10;
return `${Math.min(contentHeight, maxEditorHeight)}px`;
}, [content, lineHeight, maxEditorHeight]);
const onUpdateMessageType = (newMode) => {
const currentMessages = [...(body.ws || [])];
@@ -173,8 +179,8 @@ export const SingleWSMessage = ({
return (
<StyledWrapper
className={!isSelected ? 'disabled' : ''}
onMouseDownCapture={() => {
if (!isSelected) setTimeout(onSelect, 0);
onMouseUpCapture={() => {
if (!isSelected) onSelect();
}}
>
<div
@@ -221,10 +227,7 @@ export const SingleWSMessage = ({
<span
className="message-label"
data-testid={`ws-message-label-${index}`}
onClick={(e) => {
e.preventDefault();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
onDoubleClick={handleNameClick}
>
{displayName}
@@ -251,7 +254,11 @@ export const SingleWSMessage = ({
</div>
</div>
{isExpanded && (
<div className="accordion-body" data-testid={`ws-message-body-${index}`} style={{ height: editorHeight }}>
<div
className="accordion-body"
data-testid={`ws-message-body-${index}`}
style={{ height: editorHeight }}
>
<CodeEditor
collection={collection}
theme={displayedTheme}
@@ -263,6 +270,7 @@ export const SingleWSMessage = ({
onSave={onSave}
mode={codemirrorMode[displayMode] ?? 'text/plain'}
enableVariableHighlighting={true}
readOnly={isSelected ? false : 'nocursor'}
/>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { IconPlus } from '@tabler/icons';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { usePersistedState } from 'hooks/usePersistedState';
import StyledWrapper from './StyledWrapper';
import { SingleWSMessage } from './SingleWSMessage/index';
@@ -14,6 +15,10 @@ const getSelectedIndex = (messages) => {
const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
const dispatch = useDispatch();
const messagesContainerRef = useRef(null);
const [listScrollTop, setListScrollTop] = usePersistedState({
key: `ws-list-scroll-${item.uid}`,
default: 0
});
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const messages = body?.ws || [];
@@ -27,6 +32,19 @@ const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
const [newMessageUid, setNewMessageUid] = useState(null);
const prevMessagesLengthRef = useRef(messages.length);
// Track the message pane's height so an expanded editor can be capped to fit
// inside it. A taller editor would overflow the pane and produce a second
// scrollbar (the list) on top of the editor's own scroll.
const [paneHeight, setPaneHeight] = useState(0);
useEffect(() => {
const el = messagesContainerRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => setPaneHeight(el.clientHeight));
ro.observe(el);
setPaneHeight(el.clientHeight);
return () => ro.disconnect();
}, []);
const setSelectedIndex = useCallback((index) => {
const currentMessages = [...(body?.ws || [])];
const updated = currentMessages.map((msg, i) => ({
@@ -68,6 +86,11 @@ const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
setNewMessageUid(newMsg.uid);
setSelectedIndex(messages.length - 1);
}
if (messagesContainerRef.current) {
const container = messagesContainerRef.current;
container.scrollTop = container.scrollHeight;
}
}
prevMessagesLengthRef.current = messages.length;
}, [messages.length]);
@@ -76,13 +99,54 @@ const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
setNewMessageUid(null);
}, []);
// Auto-scroll to bottom when new message is added
// Restore the last scroll position on mount (component remounts on tab switch,
// so listScrollTop is read synchronously for this request).
useEffect(() => {
if (messagesContainerRef.current && messages.length > 0) {
const container = messagesContainerRef.current;
container.scrollTop = container.scrollHeight;
const container = messagesContainerRef.current;
if (container) {
container.scrollTop = listScrollTop;
}
}, [messages.length]);
}, [item.uid]);
const handleScroll = useCallback(() => {
const container = messagesContainerRef.current;
if (container) {
setListScrollTop(container.scrollTop);
}
}, [setListScrollTop]);
// Clicking or typing in an editor makes the browser scroll the list to reveal
// CodeMirror's cursor, flinging the whole panel. Pin the list's scrollTop for a
// few frames so focus/keystrokes can't move it (the editor still scrolls
// internally); a real user scroll (wheel/touch) releases the pin.
const pinScrollRef = useRef(null);
const pinListScroll = useCallback(() => {
const container = messagesContainerRef.current;
if (!container) return;
const top = container.scrollTop;
let frames = 0;
const pin = () => {
const el = messagesContainerRef.current;
if (!el || pinScrollRef.current === null) return;
if (el.scrollTop !== top) el.scrollTop = top;
if (++frames < 8) {
pinScrollRef.current = requestAnimationFrame(pin);
} else {
pinScrollRef.current = null;
}
};
// Cancel any in-flight pin so a fresh gesture re-snapshots the current top.
if (pinScrollRef.current !== null) cancelAnimationFrame(pinScrollRef.current);
pinScrollRef.current = requestAnimationFrame(pin);
}, []);
// A real user scroll (wheel/touch) releases the pin immediately.
const releasePin = useCallback(() => {
if (pinScrollRef.current !== null) {
cancelAnimationFrame(pinScrollRef.current);
pinScrollRef.current = null;
}
}, []);
if (!messages.length) {
return (
@@ -100,11 +164,19 @@ const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
return (
<StyledWrapper>
<div ref={messagesContainerRef} className="messages-container">
<div
ref={messagesContainerRef}
className="messages-container"
data-testid="ws-messages-container"
onScroll={handleScroll}
onMouseDownCapture={pinListScroll}
onKeyDownCapture={pinListScroll}
onWheel={releasePin}
onTouchMove={releasePin}
>
{messages.map((message, index) => (
<SingleWSMessage
key={message.uid}
id={`ws-message-${message.uid}`}
message={message}
item={item}
collection={collection}
@@ -116,6 +188,7 @@ const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
onNewRendered={handleNewMessageRendered}
isSelected={selectedIndex === index}
onSelect={() => handleSelect(index)}
paneHeight={paneHeight}
/>
))}
</div>

View File

@@ -0,0 +1,452 @@
meta {
name: ws-long-msg
type: ws
seq: 4
}
ws {
url: ws://localhost:8081/ws/echo
body: ws
auth: inherit
}
body:ws {
name: long message
type: json
content: '''
{
"type": "bulk",
"count": 60,
"items": [
{
"id": 0,
"name": "item-0",
"active": true
},
{
"id": 1,
"name": "item-1",
"active": false
},
{
"id": 2,
"name": "item-2",
"active": true
},
{
"id": 3,
"name": "item-3",
"active": false
},
{
"id": 4,
"name": "item-4",
"active": true
},
{
"id": 5,
"name": "item-5",
"active": false
},
{
"id": 6,
"name": "item-6",
"active": true
},
{
"id": 7,
"name": "item-7",
"active": false
},
{
"id": 8,
"name": "item-8",
"active": true
},
{
"id": 9,
"name": "item-9",
"active": false
},
{
"id": 10,
"name": "item-10",
"active": true
},
{
"id": 11,
"name": "item-11",
"active": false
},
{
"id": 12,
"name": "item-12",
"active": true
},
{
"id": 13,
"name": "item-13",
"active": false
},
{
"id": 14,
"name": "item-14",
"active": true
},
{
"id": 15,
"name": "item-15",
"active": false
},
{
"id": 16,
"name": "item-16",
"active": true
},
{
"id": 17,
"name": "item-17",
"active": false
},
{
"id": 18,
"name": "item-18",
"active": true
},
{
"id": 19,
"name": "item-19",
"active": false
},
{
"id": 20,
"name": "item-20",
"active": true
},
{
"id": 21,
"name": "item-21",
"active": false
},
{
"id": 22,
"name": "item-22",
"active": true
},
{
"id": 23,
"name": "item-23",
"active": false
},
{
"id": 24,
"name": "item-24",
"active": true
},
{
"id": 25,
"name": "item-25",
"active": false
},
{
"id": 26,
"name": "item-26",
"active": true
},
{
"id": 27,
"name": "item-27",
"active": false
},
{
"id": 28,
"name": "item-28",
"active": true
},
{
"id": 29,
"name": "item-29",
"active": false
},
{
"id": 30,
"name": "item-30",
"active": true
},
{
"id": 31,
"name": "item-31",
"active": false
},
{
"id": 32,
"name": "item-32",
"active": true
},
{
"id": 33,
"name": "item-33",
"active": false
},
{
"id": 34,
"name": "item-34",
"active": true
},
{
"id": 35,
"name": "item-35",
"active": false
},
{
"id": 36,
"name": "item-36",
"active": true
},
{
"id": 37,
"name": "item-37",
"active": false
},
{
"id": 38,
"name": "item-38",
"active": true
},
{
"id": 39,
"name": "item-39",
"active": false
},
{
"id": 40,
"name": "item-40",
"active": true
},
{
"id": 41,
"name": "item-41",
"active": false
},
{
"id": 42,
"name": "item-42",
"active": true
},
{
"id": 43,
"name": "item-43",
"active": false
},
{
"id": 44,
"name": "item-44",
"active": true
},
{
"id": 45,
"name": "item-45",
"active": false
},
{
"id": 46,
"name": "item-46",
"active": true
},
{
"id": 47,
"name": "item-47",
"active": false
},
{
"id": 48,
"name": "item-48",
"active": true
},
{
"id": 49,
"name": "item-49",
"active": false
},
{
"id": 50,
"name": "item-50",
"active": true
},
{
"id": 51,
"name": "item-51",
"active": false
},
{
"id": 52,
"name": "item-52",
"active": true
},
{
"id": 53,
"name": "item-53",
"active": false
},
{
"id": 54,
"name": "item-54",
"active": true
},
{
"id": 55,
"name": "item-55",
"active": false
},
{
"id": 56,
"name": "item-56",
"active": true
},
{
"id": 57,
"name": "item-57",
"active": false
},
{
"id": 58,
"name": "item-58",
"active": true
},
{
"id": 59,
"name": "item-59",
"active": false
}
]
}
'''
}
body:ws {
name: short message
type: text
content: '''
hello world
'''
}
body:ws {
name: filler message 1
type: text
content: '''
filler 1
'''
}
body:ws {
name: filler message 2
type: text
content: '''
filler 2
'''
}
body:ws {
name: filler message 3
type: text
content: '''
filler 3
'''
}
body:ws {
name: filler message 4
type: text
content: '''
filler 4
'''
}
body:ws {
name: filler message 5
type: text
content: '''
filler 5
'''
}
body:ws {
name: filler message 6
type: text
content: '''
filler 6
'''
}
body:ws {
name: filler message 7
type: text
content: '''
filler 7
'''
}
body:ws {
name: filler message 8
type: text
content: '''
filler 8
'''
}
body:ws {
name: filler message 9
type: text
content: '''
filler 9
'''
}
body:ws {
name: filler message 10
type: text
content: '''
filler 10
'''
}
body:ws {
name: filler message 11
type: text
content: '''
filler 11
'''
}
body:ws {
name: filler message 12
type: text
content: '''
filler 12
'''
}
body:ws {
name: filler message 13
type: text
content: '''
filler 13
'''
}
body:ws {
name: filler message 14
type: text
content: '''
filler 14
'''
}
body:ws {
name: filler message 15
type: text
content: '''
filler 15
'''
}

View File

@@ -0,0 +1,324 @@
meta {
name: ws-scroll-top
type: ws
seq: 5
}
ws {
url: ws://localhost:8081/ws/echo
body: ws
auth: inherit
}
body:ws {
name: message 1
type: json
content: '''
{
"type": "bulk",
"count": 60,
"items": [
{
"id": 0,
"name": "item-0",
"active": true
},
{
"id": 1,
"name": "item-1",
"active": false
},
{
"id": 2,
"name": "item-2",
"active": true
},
{
"id": 3,
"name": "item-3",
"active": false
},
{
"id": 4,
"name": "item-4",
"active": true
},
{
"id": 5,
"name": "item-5",
"active": false
},
{
"id": 6,
"name": "item-6",
"active": true
},
{
"id": 7,
"name": "item-7",
"active": false
},
{
"id": 8,
"name": "item-8",
"active": true
},
{
"id": 9,
"name": "item-9",
"active": false
},
{
"id": 10,
"name": "item-10",
"active": true
},
{
"id": 11,
"name": "item-11",
"active": false
},
{
"id": 12,
"name": "item-12",
"active": true
},
{
"id": 13,
"name": "item-13",
"active": false
},
{
"id": 14,
"name": "item-14",
"active": true
},
{
"id": 15,
"name": "item-15",
"active": false
},
{
"id": 16,
"name": "item-16",
"active": true
},
{
"id": 17,
"name": "item-17",
"active": false
},
{
"id": 18,
"name": "item-18",
"active": true
},
{
"id": 19,
"name": "item-19",
"active": false
},
{
"id": 20,
"name": "item-20",
"active": true
},
{
"id": 21,
"name": "item-21",
"active": false
},
{
"id": 22,
"name": "item-22",
"active": true
},
{
"id": 23,
"name": "item-23",
"active": false
},
{
"id": 24,
"name": "item-24",
"active": true
},
{
"id": 25,
"name": "item-25",
"active": false
},
{
"id": 26,
"name": "item-26",
"active": true
},
{
"id": 27,
"name": "item-27",
"active": false
},
{
"id": 28,
"name": "item-28",
"active": true
},
{
"id": 29,
"name": "item-29",
"active": false
},
{
"id": 30,
"name": "item-30",
"active": true
},
{
"id": 31,
"name": "item-31",
"active": false
},
{
"id": 32,
"name": "item-32",
"active": true
},
{
"id": 33,
"name": "item-33",
"active": false
},
{
"id": 34,
"name": "item-34",
"active": true
},
{
"id": 35,
"name": "item-35",
"active": false
},
{
"id": 36,
"name": "item-36",
"active": true
},
{
"id": 37,
"name": "item-37",
"active": false
},
{
"id": 38,
"name": "item-38",
"active": true
},
{
"id": 39,
"name": "item-39",
"active": false
},
{
"id": 40,
"name": "item-40",
"active": true
},
{
"id": 41,
"name": "item-41",
"active": false
},
{
"id": 42,
"name": "item-42",
"active": true
},
{
"id": 43,
"name": "item-43",
"active": false
},
{
"id": 44,
"name": "item-44",
"active": true
},
{
"id": 45,
"name": "item-45",
"active": false
},
{
"id": 46,
"name": "item-46",
"active": true
},
{
"id": 47,
"name": "item-47",
"active": false
},
{
"id": 48,
"name": "item-48",
"active": true
},
{
"id": 49,
"name": "item-49",
"active": false
},
{
"id": 50,
"name": "item-50",
"active": true
},
{
"id": 51,
"name": "item-51",
"active": false
},
{
"id": 52,
"name": "item-52",
"active": true
},
{
"id": 53,
"name": "item-53",
"active": false
},
{
"id": 54,
"name": "item-54",
"active": true
},
{
"id": 55,
"name": "item-55",
"active": false
},
{
"id": 56,
"name": "item-56",
"active": true
},
{
"id": 57,
"name": "item-57",
"active": false
},
{
"id": 58,
"name": "item-58",
"active": true
},
{
"id": 59,
"name": "item-59",
"active": false
}
]
}
'''
}

View File

@@ -0,0 +1,42 @@
import { expect, test } from '../../playwright';
import { openRequest, closeAllCollections } from '../utils/page/actions';
const COLLECTION_NAME = 'collection';
const SCROLL_REQ = 'ws-scroll-top';
test.describe('websocket message editor scroll behaviour', () => {
test.afterEach(async ({ pageWithUserData: page }) => {
await closeAllCollections(page);
});
test('reopening a message restores the scroll position where we left it', async ({ pageWithUserData: page }) => {
await openRequest(page, COLLECTION_NAME, SCROLL_REQ);
const header = page.getByTestId('ws-message-header-0');
const body = page.getByTestId('ws-message-body-0');
await expect(body).toBeVisible();
const cmScroll = body.locator('.CodeMirror-scroll');
await expect
.poll(() => cmScroll.evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeGreaterThan(0);
// Scroll down into the body.
const target = await cmScroll.evaluate((el) => {
el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) * 0.6);
return el.scrollTop;
});
expect(target).toBeGreaterThan(0);
// Collapse then reopen.
await header.click({ position: { x: 8, y: 12 } });
await expect(body).toBeHidden();
await header.click({ position: { x: 8, y: 12 } });
await expect(body).toBeVisible();
// Editor should return to where we left it, not to the top.
await expect
.poll(() => page.getByTestId('ws-message-body-0').locator('.CodeMirror-scroll').evaluate((el) => el.scrollTop))
.toBeGreaterThan(target - 60);
});
});

View File

@@ -0,0 +1,79 @@
import { expect, test } from '../../playwright';
import { openRequest, selectRequestPaneTab, closeAllCollections } from '../utils/page/actions';
const COLLECTION_NAME = 'collection';
const LONG_MSG_REQ = 'ws-long-msg';
test.describe('websocket message list scroll on tab switch', () => {
test.afterEach(async ({ pageWithUserData: page }) => {
await closeAllCollections(page);
});
test('does not scroll the expanded message list when switching request pane tabs', async ({
pageWithUserData: page
}) => {
await openRequest(page, COLLECTION_NAME, LONG_MSG_REQ);
const container = page.getByTestId('ws-messages-container');
await expect(container).toBeVisible();
await expect(page.getByTestId('ws-message-body-0')).toBeVisible();
// the container must actually be scrollable.
await expect
.poll(() => container.evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeGreaterThan(0);
// Start from the top of the list.
await container.evaluate((el) => {
el.scrollTop = 0;
});
await expect.poll(() => container.evaluate((el) => el.scrollTop)).toBe(0);
// Switch away from the Message tab and back — this remounts WsBody.
await selectRequestPaneTab(page, 'Headers');
await expect(container).toBeHidden();
await selectRequestPaneTab(page, 'Message');
await expect(container).toBeVisible();
await expect(page.getByTestId('ws-message-body-0')).toBeVisible();
// The scroll position must not have jumped to the bottom on remount.
await expect.poll(() => container.evaluate((el) => el.scrollTop)).toBe(0);
});
test('restores the scroll position when switching back to the Message tab', async ({
pageWithUserData: page
}) => {
await openRequest(page, COLLECTION_NAME, LONG_MSG_REQ);
const container = page.getByTestId('ws-messages-container');
await expect(container).toBeVisible();
await expect(page.getByTestId('ws-message-body-0')).toBeVisible();
// The container must actually be scrollable.
await expect
.poll(() => container.evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeGreaterThan(0);
// Scroll to a specific position partway down the list.
const targetScroll = await container.evaluate((el) => {
const target = Math.floor((el.scrollHeight - el.clientHeight) / 2);
el.scrollTop = target;
return el.scrollTop;
});
expect(targetScroll).toBeGreaterThan(0);
await expect.poll(() => container.evaluate((el) => el.scrollTop)).toBe(targetScroll);
// Switch away from the Message tab and back — this remounts WsBody.
await selectRequestPaneTab(page, 'Headers');
await expect(container).toBeHidden();
await selectRequestPaneTab(page, 'Message');
await expect(container).toBeVisible();
await expect(page.getByTestId('ws-message-body-0')).toBeVisible();
// The scroll position must be restored to where we left off.
await expect.poll(() => container.evaluate((el) => el.scrollTop)).toBe(targetScroll);
});
});

View File

@@ -0,0 +1,49 @@
import { expect, test } from '../../playwright';
import { openRequest, closeAllCollections } from '../utils/page/actions';
const COLLECTION_NAME = 'collection';
const SCROLL_REQ = 'ws-scroll-top';
const MULTI_REQ = 'ws-long-msg';
test.describe('websocket message editor typing scroll', () => {
test.afterEach(async ({ pageWithUserData: page }) => {
await closeAllCollections(page);
});
test('typing does not jump the list to the end', async ({ pageWithUserData: page }) => {
await openRequest(page, COLLECTION_NAME, SCROLL_REQ);
const container = page.getByTestId('ws-messages-container');
const body = page.getByTestId('ws-message-body-0');
await expect(container).toBeVisible();
await expect(body).toBeVisible();
const cmScroll = body.locator('.CodeMirror-scroll');
await expect
.poll(() => cmScroll.evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeGreaterThan(0);
// Scroll into the middle of the message body and place the cursor there.
await cmScroll.evaluate((el) => {
el.scrollTop = Math.floor((el.scrollHeight - el.clientHeight) / 2);
});
await body.locator('.CodeMirror').click();
const containerBefore = await container.evaluate((el) => el.scrollTop);
const cmBefore = await cmScroll.evaluate((el) => el.scrollTop);
await page.keyboard.type('x');
const containerAfter = await container.evaluate((el) => el.scrollTop);
const cmAfter = await cmScroll.evaluate((el) => el.scrollTop);
const listMoved = Math.abs(containerAfter - containerBefore);
const editorMoved = Math.abs(cmAfter - cmBefore);
// Typing on the same, already-visible line must not scroll anything.
// The bug flung the list (and would fling the editor) to the very end;
// healthy behaviour is zero movement (≤ 1px allows for fractional scrollTop).
expect(listMoved).toBeLessThanOrEqual(1);
expect(editorMoved).toBeLessThanOrEqual(1);
});
});